@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
@@ -0,0 +1,97 @@
1
+ import { unwatchFile, watchFile } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { resolveMoondogAuthFile } from "../runtime/pi/persistent-credential-store.mjs";
6
+
7
+ // The music service that plays songs. Apple Music plays whole songs or 30-second previews;
8
+ // NetEase Cloud Music plays through its own ncm-cli, for listeners in mainland China.
9
+ export const PLAYBACK_SERVICES = Object.freeze(["spotify", "apple_music", "netease"]);
10
+ export const DEFAULT_PLAYBACK_SERVICE = "spotify";
11
+
12
+ export function normalizePlaybackService(value) {
13
+ return PLAYBACK_SERVICES.includes(value) ? value : null;
14
+ }
15
+
16
+ // One selection for every conversation in this process. The window and the terminal each run one,
17
+ // and watchPlaybackPreference keeps it in step with the other's saves.
18
+ // Persist before publishing a change; overlapping choices save in the order they were requested.
19
+ // The file is written even when this process already holds the value: the other may have changed it.
20
+ export function createPlaybackSelection({ service = DEFAULT_PLAYBACK_SERVICE, save } = {}) {
21
+ let current = normalizePlaybackService(service) ?? DEFAULT_PLAYBACK_SERVICE;
22
+ let changing = Promise.resolve();
23
+ const listeners = new Set();
24
+ const set = (service) => {
25
+ const value = normalizePlaybackService(service);
26
+ if (!value) throw new Error(`Unsupported music service: ${service}`);
27
+ if (value !== current) {
28
+ current = value;
29
+ for (const listener of listeners) {
30
+ try { listener(value); } catch { /* A surface cannot undo a saved choice. */ }
31
+ }
32
+ }
33
+ return value;
34
+ };
35
+ return {
36
+ get service() { return current; },
37
+ set,
38
+ change(service) {
39
+ const work = changing.then(async () => {
40
+ const value = normalizePlaybackService(service);
41
+ if (!value) throw new Error(`Unsupported music service: ${service}`);
42
+ await save?.(value);
43
+ return set(value);
44
+ });
45
+ changing = work.catch(() => {});
46
+ return work;
47
+ },
48
+ subscribe(listener) {
49
+ listeners.add(listener);
50
+ return () => listeners.delete(listener);
51
+ },
52
+ };
53
+ }
54
+
55
+ // Kept next to the language preference, apart from runtime settings.
56
+ export function resolvePlaybackPreferenceFile(environment = process.env) {
57
+ return path.join(path.dirname(resolveMoondogAuthFile(environment)), "playback.json");
58
+ }
59
+
60
+ export async function readPlaybackPreference(environment = process.env) {
61
+ try {
62
+ const document = JSON.parse(await readFile(resolvePlaybackPreferenceFile(environment), "utf8"));
63
+ return document?.version === 1 ? normalizePlaybackService(document.service) : null;
64
+ } catch {
65
+ // A missing or unreadable preference falls back to Spotify.
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export async function writePlaybackPreference(service, environment = process.env) {
71
+ const value = normalizePlaybackService(service);
72
+ if (!value) throw new Error(`Unsupported music service: ${service}`);
73
+ const file = resolvePlaybackPreferenceFile(environment);
74
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
75
+ await writeFile(file, `${JSON.stringify({ version: 1, service: value }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
76
+ return value;
77
+ }
78
+
79
+ // The terminal and the window each run their own core on this file. A service saved by one reaches
80
+ // the other's selection, which tells its picker and agents; onChange hears only those changes.
81
+ export function watchPlaybackPreference(selection, { environment = process.env, interval = 2000, onChange } = {}) {
82
+ const file = resolvePlaybackPreferenceFile(environment);
83
+ const follow = async () => {
84
+ const saved = await readPlaybackPreference(environment);
85
+ if (!saved || saved === selection.service) return;
86
+ selection.set(saved);
87
+ onChange?.(saved);
88
+ };
89
+ const listener = () => { void follow().catch(() => {}); };
90
+ watchFile(file, { interval, persistent: false }, listener);
91
+ return () => unwatchFile(file, listener);
92
+ }
93
+
94
+ /** The saved choice, or Spotify. */
95
+ export async function resolvePlaybackService(environment = process.env) {
96
+ return await readPlaybackPreference(environment) ?? DEFAULT_PLAYBACK_SERVICE;
97
+ }
@@ -0,0 +1,121 @@
1
+ // These parsers read only the actual listener message. Metadata and model tool
2
+ // arguments never create a contextual selection or an additional write grant.
3
+ const digits = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
4
+ function number(value) {
5
+ if (/^\d+$/u.test(value)) return Number(value);
6
+ if (/^[一二两三四五六七八九]?十[一二三四五六七八九]?$/u.test(value)) {
7
+ const [tens, ones] = value.split("十");
8
+ return (digits[tens] ?? 1) * 10 + (digits[ones] ?? 0);
9
+ }
10
+ return digits[value] ?? null;
11
+ }
12
+
13
+ const englishCounts = { zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9,
14
+ ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19,
15
+ twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90, hundred: 100, thousand: 1000, million: 1000000 };
16
+ const englishCountWords = Object.keys(englishCounts).join("|");
17
+ const englishCountPattern = `(?:${englishCountWords})(?:(?:[ -]+)(?:and +)?(?:${englishCountWords}))*`;
18
+ const leadingEnglishCount = new RegExp(`^${englishCountPattern}\\b`, "iu");
19
+ const mediaCounts = new RegExp(`(?<![\\p{L}\\p{N}])(?:[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)|${englishCountPattern})(?![\\p{L}\\p{N}])`, "giu");
20
+
21
+ function queueCount(command) {
22
+ const value = command.replace(/^(?:(?:only|just|exactly|another|an additional|a further|the next)\s+)+/iu, "").trim();
23
+ if (/^(?:no|none|nothing)\b/iu.test(value)) return 0;
24
+ // Chinese quantities may follow 找/推荐/刚才的 inside the accepted command.
25
+ const chinese = [...value.matchAll(/([零〇一二两三四五六七八九十百千万亿]+|[+-]?\d+(?:\.\d+)?)\s*首/gu)];
26
+ if (chinese.length > 1) return 0;
27
+ if (chinese.length === 1) return /^[+-]?\d/u.test(chinese[0][1]) ? Number(chinese[0][1]) : number(chinese[0][1]) ?? 0;
28
+ const tokens = [...value.matchAll(mediaCounts)];
29
+ if (tokens.some(match => /^\s*(?:[-/]|\b(?:and|or|to|through|point)\b)/iu.test(value.slice(match.index + match[0].length)))) return 0;
30
+ const media = tokens.filter(match => /^\s+(?:(?!\bby\b)[^\s,;]+\s+){0,8}(?:songs?|tracks?)\b/iu.test(value.slice(match.index + match[0].length)));
31
+ if (media.length > 1) return 0;
32
+ const numeric = value.match(/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?![\p{L}\p{N}])/u);
33
+ const english = value.match(leadingEnglishCount);
34
+ const count = numeric ?? english ?? media[0];
35
+ if (!count) return /^(?:a|an)\s+(?:song|track)\b/iu.test(value) ? 1 : null;
36
+ const tail = value.slice(count.index + count[0].length);
37
+ // Number words in titles (Two Princes, Seven Nation Army) are not quantities.
38
+ if (!media.length && english && tail.trim()) return null;
39
+ if (/^[+\-.\d]/u.test(count[0])) return Number(count[0]);
40
+ const words = count[0].toLowerCase().split(/[ -]+/u);
41
+ if (words.length === 1) return englishCounts[words[0]];
42
+ if (words.length === 2 && englishCounts[words[0]] >= 20 && englishCounts[words[0]] < 100 && englishCounts[words[1]] < 10) return englishCounts[words[0]] + englishCounts[words[1]];
43
+ return 0; // Unsupported compound quantities cannot fall back to the candidate pool.
44
+ }
45
+
46
+ function queueCommand(clause, hasQueue) {
47
+ const value = clause.trim().replace(/^(?:(?:please|can you|could you|would you|will you|i want (?:you )?to|i would like (?:you )?to)\s+)+/iu, "");
48
+ if (/^(?:请|帮我|给我|可以|能否)*(?:问|告诉|解释|说明|介绍|看看|查看|检查|显示|列出|读取|怎么|如何|为什么)/u.test(value)) return null;
49
+ const queue = value.match(/^queue\s+(.+)/iu);
50
+ if (queue && !/^(?:has|have|had|is|are|was|were|will|would|can|could|should|must|may|might|does|doesn't|do|don't|did|didn't|keeps?|contains?|includes?|needs?|looks?|seems?|already|currently|status|length|size)\b/iu.test(queue[1])) return queue[1];
51
+ const additive = value.match(/^(?:add|put|place)\s+(.+?)\s+(?:to|into|in|on)\s+(?:(?:the|my)\s+)?queue\b/iu);
52
+ if (additive) return additive[1];
53
+ const chineseAdditive = value.match(/^(?:(?:请|帮我|给我)\s*)?(?:加|放|排)\s*(.+?)(?:到|进)(?:我的)?队列/u);
54
+ if (chineseAdditive) return chineseAdditive[1];
55
+ if (hasQueue && /^(?:再来|给我|帮我|请|把|将|加入队列|加到队列|放到队列|排进队列)/u.test(value)) {
56
+ return value.replace(/^(?:(?:再来|给我|帮我|请|把|将|加入队列|加到队列|放到队列|排进队列)\s*)+/u, "");
57
+ }
58
+ return null;
59
+ }
60
+
61
+ export function queueCancellationRequested(text) {
62
+ if (typeof text !== "string") return false;
63
+ const value = text.normalize("NFKC").replace(/[‘’]/gu, "'").trim();
64
+ return /^[¡]?(?:cancel|stop|算了|取消|不用了|别加了|不要加了|cancela|cancelar|para|detente|d[eé]jalo|olv[ií]dalo|ya no)[.!。!]?$/iu.test(value) ||
65
+ /\b(?:don't|do not|never|stop|cancel|not to)\b[^,,。;;!?!?]*\bqueue\b|(?:不要|别|取消|停止)[^,。!?!?]{0,40}(?:queue|队列)/iu.test(value) ||
66
+ /(?:^|[\s¡¿,])(?:no|nunca|deja de|para de|cancela)\s[^,.;!?¡¿]{0,40}\bcola\b/iu.test(value) ||
67
+ /^(?:cancela|cancelar|para|pare|chega|esquece|deixa pra l[aá])[.!]?$/iu.test(value) ||
68
+ /(?:^|[\s,])(?:n[aã]o|nunca|para de|pare de|cancela)\s[^,.;!?]{0,40}\bfila\b/iu.test(value) ||
69
+ /^(?:キャンセル|やめて|止めて|中止)(?:して)?(?:ください)?[。!!]?$|(?:キュー|再生待ち)[^。!?]{0,20}(?:入れないで|追加しないで|やめて)|追加しないで/u.test(value);
70
+ }
71
+
72
+ /** The listener asked for music they have not heard, in any supported language. */
73
+ export function unheardRequested(text) {
74
+ return typeof text === "string" &&
75
+ /没听过|从未听|不要听过|\bunheard\b|\bnever heard\b|outside.*library|曲库之外|nunca he (?:escuchado|o[ií]do)|que no (?:he|haya) (?:escuchado|o[ií]do)|sin escuchar|nuevas para m[ií]|nunca ouvi|que eu (?:ainda )?n[aã]o (?:ouvi|conhe[cç]o)|novas pra mim|novas para mim|聴いたことのない|聞いたことのない|知らない曲|未聴/iu.test(text);
76
+ }
77
+
78
+ function queueRefinement(value, previous) {
79
+ if (!previous || previous.attempted || value.length > 500) return null;
80
+ // A count or musical version choice completes a recent, unfinished listener
81
+ // request. It never creates authority without that original queue request.
82
+ const clauses = value.split(/[,,。;;!?!?\n]|\.(?:\s|$)/u).map(clause => clause.trim()).filter(Boolean);
83
+ const countStart = new RegExp(`^(?:${englishCountPattern}|[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+))\\s+(?:more\\s+)?(?:songs?|tracks?)\\b`, "iu");
84
+ const counts = clauses.filter(clause =>
85
+ /^(?:(?:队列(?:数量|首数)?|数量|首数)\s*(?:先来|就要|来|要|改成|改为)?|先来|来|就要|改成|改为)?\s*(?:[零〇一二两三四五六七八九十百千万亿]+|[+-]?\d+(?:\.\d+)?)\s*首/u.test(clause) ||
86
+ (countStart.test(clause) && !/\b(?:are|is|were|was|already|have|has|had)\b/iu.test(clause)))
87
+ .map(queueCount).filter(count => count !== null);
88
+ const choice = value.replace(/[,,.!。!??]/gu, " ").trim();
89
+ const versionChoice = /^(?:(?:掺|混|加|选|用|要|就|还是|只要|一点|一些|纯)\s*)*(?:翻唱|原唱|现场|录音室|女声|男声|纯音乐|老歌)(?:版本|版|气质)?(?:\s*(?:和|加|也|都|一点|一些|吧|的|翻唱|原唱|现场|录音室|女声|男声|纯音乐|老歌))*$/u.test(choice) ||
90
+ /^(?:(?:mix in|include|add|choose|use|only|just|some|a few)\s+)*(?:covers?|originals?|original versions?|live versions?|studio versions?|instrumentals?|female vocals?|male vocals?)(?:\s+please)?$/iu.test(choice);
91
+ if (!counts.length && !versionChoice) return null;
92
+ const ambiguous = counts.length > 1 || /[,,;;]\s*(?:actually|instead|make that|only|改成|改为|其实)/iu.test(value);
93
+ return { requested: ambiguous ? 0 : counts[0] ?? previous.requested,
94
+ request: previous.request, refinement: value, queue_only: true,
95
+ excludeKnown: previous.excludeKnown === true };
96
+ }
97
+
98
+ export function queueListeningIntent(text, previous = null) {
99
+ if (typeof text !== "string" || text.length > 2000) return null;
100
+ const value = text.normalize("NFKC").replace(/[‘’]/gu, "'").trim().replace(/\b\d{1,3}(?:,\d{3})+(?:\.\d+)?\b/gu, count => count.replaceAll(",", ""));
101
+ const hasQueue = /\bqueue\b|播放队列|(?:加入|加到|放到|排进|到|进)(?:我的)?队列/iu.test(value);
102
+ if (queueCancellationRequested(value) ||
103
+ /\b(?:explain|describe|what(?:'s| is)|why|how|whether|tell me about|can i|should i|do i|does|show|inspect|list|read|check)\b.*\bqueue\b|^(?:为什么|怎么|如何|查看|看看|显示|读取|队列里|请问|(?:帮我|给我)(?:看看|解释|说明|介绍)|(?:请|可以|能否)?(?:解释|说明|介绍))/iu.test(value)) return null;
104
+ if (!hasQueue) return queueRefinement(value, previous);
105
+ // A correction can reuse only the host's recent request, including its receipt.
106
+ const correction = /^(?:i meant (?:the )?queue(?:\s*[,,]?\s*not (?:a )?playlist)?|queue\s*[,,]\s*not (?:a )?playlist|(?:我)?要的是\s*queue\s*[,,]?\s*不是歌单)[.!。!]?$/iu.test(value);
107
+ if (correction && !previous) return null;
108
+ const clauses = value.split(/[,,。;;!?!?]|\.(?:\s|$)/u);
109
+ const commands = correction ? [] : clauses.map(clause => queueCommand(clause, hasQueue)).filter(command => command !== null);
110
+ if (!correction && commands.length === 0) return queueRefinement(value, previous);
111
+ const counts = commands.map(queueCount);
112
+ // Multiple commands or a revised quantity need an exact request before writing.
113
+ const ambiguous = commands.length > 1 || (counts[0] === null && clauses.some(clause => queueCommand(clause, hasQueue) === null && queueCount(clause) !== null)) || /\d,\d/u.test(value) || /[,,;;]\s*(?:actually|instead|make that|only|改成|改为|其实)/iu.test(value);
114
+ const requested = correction ? previous.requested : ambiguous ? 0 : counts[0];
115
+ const affirmative = value.replace(/(?:don't|don’t|do not|never)\s+[^,,.;;]+|(?:不要|别)[^,。;;]+/giu, "").replaceAll("播放队列", "队列");
116
+ return { requested,
117
+ request: correction ? previous.request : value,
118
+ queue_only: correction || !/\b(?:play|resume|pause|skip|next|previous|seek|volume|shuffle|repeat|transfer|save|create|follow|unfollow)\b|播放|暂停|下一首|上一首|音量|切换设备|保存|创建|关注/iu.test(affirmative),
119
+ ...(correction && previous.attempted ? { clarification_only: true } : {}),
120
+ excludeKnown: unheardRequested(value) || (correction && previous.excludeKnown === true) };
121
+ }
@@ -252,7 +252,7 @@ function safeTrackResult(track) {
252
252
  result.discovery_basis = structuredClone(track.discoveryBasis);
253
253
  }
254
254
  result.knownness = {
255
- imported_library: "not_found_by_exact_title_artist",
255
+ imported_library: track.knownLibraryMatch ? "found_by_exact_title_artist" : "not_found_by_exact_title_artist",
256
256
  listening_history: "not_checked",
257
257
  };
258
258
  if (track.primaryGenre) result.primary_genre = track.primaryGenre;
@@ -962,6 +962,21 @@ export class SyntheticDomainServices {
962
962
  }
963
963
  }
964
964
 
965
+ // Each conversation that runs beside another gets a fork. It shares the fixture's tracks,
966
+ // profile and evidence, which never change after construction, and keeps its own candidate
967
+ // sets and outside tracks, so one conversation's prompt never clears another's.
968
+ fork() {
969
+ const fork = new SyntheticDomainServices({
970
+ subjectScope: this.#subjectId,
971
+ fixture: { tracks: [] },
972
+ });
973
+ fork.#tracks = this.#tracks;
974
+ fork.#tracksById = new Map(this.#tracks.map((track) => [track.trackRefId, track]));
975
+ fork.#profile = this.#profile;
976
+ fork.#evidenceById = this.#evidenceById;
977
+ return fork;
978
+ }
979
+
965
980
  status() {
966
981
  return {
967
982
  state: "ready",
@@ -990,6 +1005,11 @@ export class SyntheticDomainServices {
990
1005
  return { invalidated_candidate_sets: invalidated };
991
1006
  }
992
1007
 
1008
+ // There is no store to close; a closed instance only lets go of its prompt state.
1009
+ close() {
1010
+ this.resetCandidateSets();
1011
+ }
1012
+
993
1013
  async searchLibrary(argumentsValue) {
994
1014
  const input = modelArguments(
995
1015
  argumentsValue,
@@ -1064,7 +1084,7 @@ export class SyntheticDomainServices {
1064
1084
  };
1065
1085
  }
1066
1086
 
1067
- registerExternalCandidateSet({ tracks, source } = {}) {
1087
+ registerExternalCandidateSet({ tracks, source, excludeKnown = true } = {}) {
1068
1088
  const provider =
1069
1089
  isPlainObject(source) && externalCatalogProviders.has(source.provider)
1070
1090
  ? source.provider
@@ -1101,10 +1121,11 @@ export class SyntheticDomainServices {
1101
1121
  normalizeSearchText(track.artistCredit) ===
1102
1122
  normalizeSearchText(internal.artistCredit),
1103
1123
  );
1104
- if (libraryMatch) {
1124
+ if (excludeKnown && libraryMatch) {
1105
1125
  excludedLibraryMatches += 1;
1106
1126
  continue;
1107
1127
  }
1128
+ internal.knownLibraryMatch = libraryMatch;
1108
1129
  if (this.#tracksById.has(internal.trackRefId)) {
1109
1130
  fail("external_candidate_invalid", "External catalog candidates are duplicated.");
1110
1131
  }
@@ -65,30 +65,12 @@ const offlineSteps = Object.freeze({
65
65
 
66
66
  const demoNextSteps = Object.freeze([
67
67
  Object.freeze({
68
- id: "open_visual_tour",
69
- title: "Open the visual Listening Time Machine",
70
- checkout_command: "npm run demo:studio",
71
- installed_command: "moondog studio --demo",
72
- data_boundary:
73
- "The fictional tour stays on 127.0.0.1, reads no private history, rejects imports, and keeps corrections only in process memory.",
74
- }),
75
- Object.freeze({
76
- id: "open_session_studio",
77
- title: "Open your history without importing it",
78
- checkout_command:
79
- "npm --silent run demo:studio -- --from /absolute/path/to/spotify-history.zip",
80
- installed_command:
81
- "moondog studio --from /absolute/path/to/spotify-history.zip",
82
- data_boundary:
83
- "No dependency installation is required. The archive stays unchanged, the private profile and corrections live only in process memory, and no model, provider, cloud, or network request is made.",
84
- }),
85
- Object.freeze({
86
- id: "open_private_studio",
68
+ id: "open_listening_room",
87
69
  title: "Bring your own listening history",
88
- checkout_command: "npm run studio",
89
- installed_command: "moondog studio",
70
+ checkout_command: "npm start",
71
+ installed_command: "moondog",
90
72
  data_boundary:
91
- "Studio stays on 127.0.0.1. The fictional preview saves nothing. Import changes private local state only after an explicit button press.",
73
+ "Your listening room opens in this terminal. Type /import there; Moondog shows what is inside an export before anything is saved.",
92
74
  }),
93
75
  ]);
94
76
 
@@ -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
+ }