@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,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,124 @@
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
+ /**
73
+ * The listener asked for music they have not heard, in any supported language. "Not heard" counts
74
+ * only when it ends there or says yet, before or ever: "haven't heard in a while" asks to come back.
75
+ */
76
+ export function unheardRequested(text) {
77
+ return typeof text === "string" &&
78
+ /没听过|从未听|不要听过|\bunheard\b|\bnever (?:heard|listened to)\b|\b(?:haven't|have not|'ve not|not)\s+(?:yet\s+|ever\s+)?(?:heard|listened to)(?:\s+(?:yet|before|of|ever|please)\b|\s*(?:$|[,.;!?,。;!?]))|\bnew (?:to|for) me\b|\bunfamiliar\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.replace(/[\u2018\u2019]/gu, "'"));
79
+ }
80
+
81
+ function queueRefinement(value, previous) {
82
+ if (!previous || previous.attempted || value.length > 500) return null;
83
+ // A count or musical version choice completes a recent, unfinished listener
84
+ // request. It never creates authority without that original queue request.
85
+ const clauses = value.split(/[,,。;;!?!?\n]|\.(?:\s|$)/u).map(clause => clause.trim()).filter(Boolean);
86
+ const countStart = new RegExp(`^(?:${englishCountPattern}|[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+))\\s+(?:more\\s+)?(?:songs?|tracks?)\\b`, "iu");
87
+ const counts = clauses.filter(clause =>
88
+ /^(?:(?:队列(?:数量|首数)?|数量|首数)\s*(?:先来|就要|来|要|改成|改为)?|先来|来|就要|改成|改为)?\s*(?:[零〇一二两三四五六七八九十百千万亿]+|[+-]?\d+(?:\.\d+)?)\s*首/u.test(clause) ||
89
+ (countStart.test(clause) && !/\b(?:are|is|were|was|already|have|has|had)\b/iu.test(clause)))
90
+ .map(queueCount).filter(count => count !== null);
91
+ const choice = value.replace(/[,,.!。!??]/gu, " ").trim();
92
+ const versionChoice = /^(?:(?:掺|混|加|选|用|要|就|还是|只要|一点|一些|纯)\s*)*(?:翻唱|原唱|现场|录音室|女声|男声|纯音乐|老歌)(?:版本|版|气质)?(?:\s*(?:和|加|也|都|一点|一些|吧|的|翻唱|原唱|现场|录音室|女声|男声|纯音乐|老歌))*$/u.test(choice) ||
93
+ /^(?:(?: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);
94
+ if (!counts.length && !versionChoice) return null;
95
+ const ambiguous = counts.length > 1 || /[,,;;]\s*(?:actually|instead|make that|only|改成|改为|其实)/iu.test(value);
96
+ return { requested: ambiguous ? 0 : counts[0] ?? previous.requested,
97
+ request: previous.request, refinement: value, queue_only: true,
98
+ excludeKnown: previous.excludeKnown === true };
99
+ }
100
+
101
+ export function queueListeningIntent(text, previous = null) {
102
+ if (typeof text !== "string" || text.length > 2000) return null;
103
+ const value = text.normalize("NFKC").replace(/[‘’]/gu, "'").trim().replace(/\b\d{1,3}(?:,\d{3})+(?:\.\d+)?\b/gu, count => count.replaceAll(",", ""));
104
+ const hasQueue = /\bqueue\b|播放队列|(?:加入|加到|放到|排进|到|进)(?:我的)?队列/iu.test(value);
105
+ if (queueCancellationRequested(value) ||
106
+ /\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;
107
+ if (!hasQueue) return queueRefinement(value, previous);
108
+ // A correction can reuse only the host's recent request, including its receipt.
109
+ const correction = /^(?:i meant (?:the )?queue(?:\s*[,,]?\s*not (?:a )?playlist)?|queue\s*[,,]\s*not (?:a )?playlist|(?:我)?要的是\s*queue\s*[,,]?\s*不是歌单)[.!。!]?$/iu.test(value);
110
+ if (correction && !previous) return null;
111
+ const clauses = value.split(/[,,。;;!?!?]|\.(?:\s|$)/u);
112
+ const commands = correction ? [] : clauses.map(clause => queueCommand(clause, hasQueue)).filter(command => command !== null);
113
+ if (!correction && commands.length === 0) return queueRefinement(value, previous);
114
+ const counts = commands.map(queueCount);
115
+ // Multiple commands or a revised quantity need an exact request before writing.
116
+ 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);
117
+ const requested = correction ? previous.requested : ambiguous ? 0 : counts[0];
118
+ const affirmative = value.replace(/(?:don't|don’t|do not|never)\s+[^,,.;;]+|(?:不要|别)[^,。;;]+/giu, "").replaceAll("播放队列", "队列");
119
+ return { requested,
120
+ request: correction ? previous.request : value,
121
+ queue_only: correction || !/\b(?:play|resume|pause|skip|next|previous|seek|volume|shuffle|repeat|transfer|save|create|follow|unfollow)\b|播放|暂停|下一首|上一首|音量|切换设备|保存|创建|关注/iu.test(affirmative),
122
+ ...(correction && previous.attempted ? { clarification_only: true } : {}),
123
+ excludeKnown: unheardRequested(value) || (correction && previous.excludeKnown === true) };
124
+ }
@@ -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
  }
@@ -0,0 +1,107 @@
1
+ // A remaster, a live take or a guest credit names a recording of the same song, so knownness,
2
+ // deduplication and Moondog's suggestion ledger compare titles without these suffixes:
3
+ // " - 2011 Remaster", "(Remastered 2009)", " - Live", "(feat. X)", "[Deluxe]". A remix stays
4
+ // its own song.
5
+ const DASH_SUFFIX = /\s+[-\u2013\u2014]\s+[^-\u2013\u2014]*\b(?:remaster(?:ed)?|live|mono|stereo|single|radio edit|edit|version|demo|acoustic|bonus track|\d{4} mix)\b[^-\u2013\u2014]*$/iu;
6
+ const BRACKET_SUFFIX = /\s*[([][^()[\]]*\b(?:remaster(?:ed)?|live|feat\.?|ft\.?|featuring|with|deluxe|mono|stereo|version|edit|demo|acoustic|bonus track)(?:\b|(?<=\.))[^()[\]]*[)\]]$/iu;
7
+
8
+ export function normalizedTrackTitle(value) {
9
+ let title = String(value ?? "").normalize("NFKC").toLocaleLowerCase("und").replace(/\s+/gu, " ").trim();
10
+ for (let previous = null; previous !== title;) {
11
+ previous = title;
12
+ const stripped = title.replace(DASH_SUFFIX, "").replace(BRACKET_SUFFIX, "").trim();
13
+ // A title made only of a version word stays as it is.
14
+ if (stripped) title = stripped;
15
+ }
16
+ return title;
17
+ }
18
+
19
+ export function normalizedArtistName(value) {
20
+ return String(value ?? "").normalize("NFKC").toLocaleLowerCase("und").replace(/\s+/gu, " ").trim();
21
+ }
22
+
23
+ /**
24
+ * One song by one primary artist, whatever the recording's version suffix. A unit separator
25
+ * joins the two, not a NUL: node:sqlite on Node 22 cuts a stored string at a NUL.
26
+ */
27
+ export function trackMatchKey(title, artist) {
28
+ const name = normalizedTrackTitle(title);
29
+ const credit = normalizedArtistName(artist);
30
+ return name && credit ? `${name}\u001f${credit}` : null;
31
+ }
32
+
33
+ // Chinese, Japanese and Korean text, which has no spaces between words (or words
34
+ // that take particles), so a name in it is found as a substring.
35
+ const CJK_TEXT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
36
+
37
+ // Folds case, width, Latin accents and punctuation, so "Beyoncé," and "beyonce" read alike.
38
+ // Kana voicing marks stay, because they change the word.
39
+ export function foldedWords(value) {
40
+ return String(value ?? "").normalize("NFKD").replace(/[̀-ͯ]/gu, "").normalize("NFC")
41
+ .toLocaleLowerCase("und").replace(/[^\p{L}\p{N}\p{M}]+/gu, " ").trim();
42
+ }
43
+
44
+ export const isCjkText = (value) => CJK_TEXT.test(String(value ?? ""));
45
+
46
+ // A letter or digit of a script that writes its words apart. A name must not start or end
47
+ // next to one of these when its own edge is one too; next to a CJK character, a space or
48
+ // punctuation, it may, so "Coldplay" is named in "我不想再听Coldplay了".
49
+ const SPACED_LETTER = /[[\p{L}\p{N}\p{M}]--[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}\p{scx=Hangul}]]/v;
50
+ const joined = (left, right) => left !== undefined && right !== undefined && SPACED_LETTER.test(left) && SPACED_LETTER.test(right);
51
+
52
+ /**
53
+ * Whether text names a name. A script change (Latin next to CJK), a space or punctuation ends
54
+ * a word, and between two Latin letters a name matches only as whole words.
55
+ * A name shorter than 2 characters after folding names nothing.
56
+ */
57
+ export function textNames(text, name) {
58
+ const wanted = foldedWords(name);
59
+ if (Array.from(wanted.replace(/ /gu, "")).length < 2) return false;
60
+ const haystack = foldedWords(text);
61
+ const first = Array.from(wanted)[0];
62
+ const last = Array.from(wanted).at(-1);
63
+ for (let at = haystack.indexOf(wanted); at >= 0; at = haystack.indexOf(wanted, at + 1)) {
64
+ const before = Array.from(haystack.slice(0, at)).at(-1);
65
+ const after = Array.from(haystack.slice(at + wanted.length))[0];
66
+ if (!joined(before, first) && !joined(last, after)) return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ /** Whether the listener's text names this song, by its title without version suffixes. */
72
+ export function textNamesTitle(text, title) {
73
+ return textNames(text, normalizedTrackTitle(title));
74
+ }
75
+
76
+ // Articles that start band names in the supported languages: "Beatles" names "The Beatles".
77
+ const LEADING_ARTICLE = /^(?:the|an|a|los|las|el|la|les|le|l|die|der|das|il|lo|i|gli|os|as|o) (?=\S)/u;
78
+
79
+ /** Whether text names an artist, with or without a leading article. */
80
+ export function textNamesArtist(text, artist) {
81
+ const name = foldedWords(artist);
82
+ const bare = name.replace(LEADING_ARTICLE, "");
83
+ // "The The" keeps its article: a bare article names nothing.
84
+ return textNames(text, name) || (bare !== name && !LEADING_ARTICLE.test(`${bare} x`) && textNames(text, bare));
85
+ }
86
+
87
+ // A quoted name: straight or curly double or single quotes, corner brackets, title marks or
88
+ // guillemets. A straight or curly single quote inside a word is an apostrophe.
89
+ const QUOTED = [/"([^"]+)"/gu, /“([^“”]+)”/gu, /„([^„“”]+)[“”]/gu, /«([^«»]+)»/gu, /「([^「」]+)」/gu, /『([^『』]+)』/gu,
90
+ /《([^《》]+)》/gu, /(?<![\p{L}\p{N}])'(?=(.+?)'(?![\p{L}\p{N}]))/gu, /‘(.+?)’(?![\p{L}\p{N}])/gu];
91
+
92
+ /** Whether text quotes this song's title, version suffixes aside. */
93
+ export function textQuotesTitle(text, title) {
94
+ const wanted = foldedWords(normalizedTrackTitle(title));
95
+ return Boolean(wanted) && QUOTED.some((pattern) => Array.from(String(text ?? "").matchAll(pattern))
96
+ .some(([, quoted]) => foldedWords(normalizedTrackTitle(quoted)) === wanted));
97
+ }
98
+
99
+ /**
100
+ * Whether the listener's text names this song as their own pick: its title with one of its
101
+ * credited artists, or its title in quotes. A title alone ("play Pink Moon") is not enough,
102
+ * because an open request shares words with titles ("a rainy day", "安静").
103
+ */
104
+ export function textNamesSong(text, title, artists = []) {
105
+ return textQuotesTitle(text, title) ||
106
+ (textNamesTitle(text, title) && artists.some((artist) => textNamesArtist(text, artist)));
107
+ }
@@ -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