@audiofool/moondog 0.1.0 → 0.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +156 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +253 -53
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +290 -55
  12. package/src/core/listener-avoids.mjs +101 -0
  13. package/src/core/listening-now.mjs +108 -0
  14. package/src/core/listening-profile-domain-services.mjs +189 -41
  15. package/src/core/listening-upkeep.mjs +31 -0
  16. package/src/core/moondog-application.mjs +2115 -107
  17. package/src/core/playback-preferences.mjs +97 -0
  18. package/src/core/spotify-listening-intent.mjs +124 -0
  19. package/src/core/synthetic-domain-services.mjs +24 -3
  20. package/src/core/track-title.mjs +107 -0
  21. package/src/demo/moondog-demo.mjs +4 -22
  22. package/src/i18n/index.mjs +285 -0
  23. package/src/i18n/messages/de.mjs +135 -0
  24. package/src/i18n/messages/en.mjs +136 -0
  25. package/src/i18n/messages/es.mjs +135 -0
  26. package/src/i18n/messages/fr.mjs +135 -0
  27. package/src/i18n/messages/it.mjs +135 -0
  28. package/src/i18n/messages/ja.mjs +135 -0
  29. package/src/i18n/messages/ko.mjs +135 -0
  30. package/src/i18n/messages/pt.mjs +135 -0
  31. package/src/i18n/messages/zh-Hant.mjs +135 -0
  32. package/src/i18n/messages/zh.mjs +135 -0
  33. package/src/i18n/preferences.mjs +34 -0
  34. package/src/i18n/screen/es.mjs +967 -0
  35. package/src/i18n/screen/ja.mjs +967 -0
  36. package/src/i18n/screen/pt.mjs +967 -0
  37. package/src/i18n/screen/zh.mjs +967 -0
  38. package/src/importers/apple-music-library/index.mjs +0 -16
  39. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  40. package/src/integrations/apple-music/catalog.mjs +166 -14
  41. package/src/integrations/apple-music/music-app.mjs +232 -0
  42. package/src/integrations/apple-music/storefront.mjs +28 -0
  43. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  44. package/src/integrations/lastfm/history.mjs +221 -0
  45. package/src/integrations/lastfm/settings.mjs +75 -0
  46. package/src/integrations/netease/ncm-cli-player.mjs +328 -0
  47. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  48. package/src/integrations/public-playlists.mjs +2 -1
  49. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  50. package/src/integrations/spotify/authentication.mjs +149 -15
  51. package/src/integrations/spotify/catalog-resolver.mjs +9 -6
  52. package/src/integrations/spotify/connection.mjs +2 -1
  53. package/src/integrations/spotify/credential-store.mjs +4 -0
  54. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  55. package/src/integrations/spotify/history-archive.mjs +1 -1
  56. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  57. package/src/integrations/spotify/recent-activity.mjs +2 -0
  58. package/src/integrations/spotify/service.mjs +873 -69
  59. package/src/integrations/spotify/web-api-client.mjs +410 -127
  60. package/src/integrations/web/brave-web.mjs +45 -0
  61. package/src/integrations/web/codex-web.mjs +15 -82
  62. package/src/integrations/web/deepseek-web.mjs +81 -0
  63. package/src/integrations/web/web-research.mjs +119 -0
  64. package/src/integrations/web/web-result.mjs +215 -0
  65. package/src/integrations/youtube-music/takeout.mjs +3 -1
  66. package/src/memory/local-memory-store.mjs +542 -57
  67. package/src/profile/history-import.mjs +42 -6
  68. package/src/profile/listener-corrections.mjs +22 -0
  69. package/src/profile/listener-profile-build.mjs +461 -0
  70. package/src/profile/listener-profile-store.mjs +130 -0
  71. package/src/profile/listener-profile-verification.mjs +68 -0
  72. package/src/profile/listener-profile-worker.mjs +179 -0
  73. package/src/profile/listening-history-store.mjs +640 -17
  74. package/src/profile/listening-profile-projection.mjs +454 -86
  75. package/src/profile/local-music-data.mjs +1 -1
  76. package/src/profile/moondog-suggestions.mjs +184 -0
  77. package/src/profile/music-providers.mjs +11 -7
  78. package/src/profile/profile-exploration.mjs +92 -0
  79. package/src/profile/recorded-measures.mjs +24 -0
  80. package/src/profile/spotify-archive-taste.mjs +0 -4
  81. package/src/runtime/pi/agent-runtime.mjs +2536 -692
  82. package/src/runtime/pi/authentication.mjs +34 -1
  83. package/src/runtime/pi/configured-runtime.mjs +22 -6
  84. package/src/runtime/pi/conversation-name.mjs +53 -0
  85. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  86. package/src/runtime/pi/model-catalog.mjs +28 -5
  87. package/src/runtime/pi/model-settings.mjs +202 -0
  88. package/src/runtime/pi/model-transport.mjs +10 -0
  89. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  90. package/src/runtime/pi/profile-build-runtime.mjs +161 -0
  91. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  92. package/src/runtime/pi/runtime-settings.mjs +10 -2
  93. package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
  94. package/src/runtime/pi/tool-trace.mjs +37 -0
  95. package/src/surfaces/cli/auth-command.mjs +12 -4
  96. package/src/surfaces/cli/brand-components.mjs +310 -132
  97. package/src/surfaces/cli/brand-theme.mjs +51 -28
  98. package/src/surfaces/cli/command-completions.mjs +58 -42
  99. package/src/surfaces/cli/format-output.mjs +199 -388
  100. package/src/surfaces/cli/history-import-view.mjs +292 -139
  101. package/src/surfaces/cli/import-guides.mjs +2 -0
  102. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  103. package/src/surfaces/cli/listening-menu.mjs +7 -4
  104. package/src/surfaces/cli/profile-command.mjs +30 -21
  105. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  106. package/src/surfaces/cli/spotify-command.mjs +54 -6
  107. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  108. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  109. package/src/surfaces/cli/terminal-art.mjs +140 -35
  110. package/src/surfaces/cli/tui.mjs +1066 -357
  111. package/src/surfaces/cli/update-command.mjs +32 -0
  112. package/src/surfaces/cli/web-command.mjs +53 -14
  113. package/src/surfaces/html/tasteprint.mjs +43 -70
  114. package/contracts/v1/capability-policy.schema.json +0 -572
  115. package/contracts/v1/profile-evidence.schema.json +0 -237
  116. package/contracts/v1/tool-invocation.schema.json +0 -318
  117. package/contracts/v1/tool-receipt.schema.json +0 -1066
  118. package/scripts/demo-studio.mjs +0 -58
  119. package/src/core/apple-data-migration.mjs +0 -50
  120. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  121. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  122. package/src/integrations/spotify/index.mjs +0 -80
  123. package/src/surfaces/web/showcase.mjs +0 -272
  124. package/src/surfaces/web/studio-page.mjs +0 -4148
  125. package/src/surfaces/web/studio.mjs +0 -2288
@@ -1,3 +1,5 @@
1
+ import { N_, screenTranslator } from "../../i18n/index.mjs";
2
+
1
3
  export function sanitizeTerminalText(value) {
2
4
  return String(value)
3
5
  .replaceAll("\r\n", "\n")
@@ -10,13 +12,16 @@ function words(value) {
10
12
  return String(value ?? "unknown").replaceAll("_", " ");
11
13
  }
12
14
 
13
- function formatSource(source) {
14
- if (!source.latest) return "- Apple Music library: not imported";
15
+ function formatSource(source, tr) {
16
+ if (!source.latest) return `- ${tr("Apple Music library: not imported")}`;
15
17
  return [
16
- `- Apple Music library: ${formatInteger(source.latest.tracks)} tracks and ${formatInteger(source.latest.playlists)} playlists, as of ${String(source.latest.captured_at).slice(0, 10)}`,
18
+ `- ${tr("Apple Music library: {tracks} and {playlists}, as of {date}", {
19
+ tracks: tr.n(source.latest.tracks, "{count} track", "{count} tracks"),
20
+ playlists: tr.n(source.latest.playlists, "{count} playlist", "{count} playlists"),
21
+ date: String(source.latest.captured_at).slice(0, 10) })}`,
17
22
  source.semantics?.complete_listening_history_available
18
- ? "- Includes a full listening history"
19
- : "- A library shows what you keep, not every play",
23
+ ? `- ${tr("Includes a full listening history")}`
24
+ : `- ${tr("A library shows what you keep, not every play")}`,
20
25
  ].join("\n");
21
26
  }
22
27
 
@@ -27,31 +32,29 @@ const serviceNames = {
27
32
  apple_music: "Apple Music",
28
33
  youtube_music: "YouTube Music",
29
34
  qq_music: "QQ Music",
30
- netease: "NetEase Cloud Music",
35
+ netease: "NetEase Music",
31
36
  };
32
37
 
33
- function formatProfile(profile) {
38
+ function formatProfile(profile, tr) {
34
39
  if (profile.state !== "ready") {
35
- return `- Not ready yet${profile.reason ? `: ${profile.reason}` : ""}\n- \`/import\` brings in your history`;
40
+ return `- ${profile.reason ? tr("Not ready yet: {reason}", { reason: profile.reason }) : tr("Not ready yet")}\n- ${tr("`/import` brings in your history")}`;
36
41
  }
37
42
  const lines = [];
38
43
  const listening = [
39
- Number.isInteger(profile.effective_listening_events)
40
- ? `${formatInteger(profile.effective_listening_events)} ${profile.effective_listening_events === 1 ? "play" : "plays"}`
41
- : null,
42
- typeof profile.listening_hours === "number" && profile.listening_hours > 0 ? `${profile.listening_hours} h` : null,
44
+ Number.isInteger(profile.effective_listening_events) ? tr.n(profile.effective_listening_events, "{count} play", "{count} plays") : null,
45
+ typeof profile.listening_hours === "number" && profile.listening_hours > 0 ? tr("{hours} h", { hours: tr.number(profile.listening_hours) }) : null,
43
46
  Array.isArray(profile.listening_sources) && profile.listening_sources.length > 0
44
- ? `from ${profile.listening_sources.map((source) => serviceNames[source] ?? words(source)).join(", ")}`
47
+ ? tr("from {services}", { services: profile.listening_sources.map((source) => serviceNames[source] ?? words(source)).join(", ") })
45
48
  : null,
46
49
  ].filter(Boolean);
47
50
  if (listening.length > 0) lines.push(`- ${listening.join(" · ")}`);
48
51
  if (Number.isInteger(profile.spotify_profile_evidence) && profile.spotify_profile_evidence > 0) {
49
- lines.push(`- ${formatInteger(profile.spotify_profile_evidence)} saved songs, follows, and playlist entries from Spotify`);
52
+ lines.push(`- ${tr.n(profile.spotify_profile_evidence, "{count} saved song, follow, or playlist entry from Spotify", "{count} saved songs, follows, and playlist entries from Spotify")}`);
50
53
  }
51
54
  return lines.join("\n");
52
55
  }
53
56
 
54
- function safeInlineText(value) {
57
+ export function safeInlineText(value) {
55
58
  return sanitizeTerminalText(value ?? "")
56
59
  .replace(/\\/gu, "\\\\")
57
60
  .replace(/([`*_[\]<>])/gu, "\\$1")
@@ -63,59 +66,44 @@ function formatInteger(value) {
63
66
  return Number.isInteger(value) ? value.toLocaleString("en-US") : "unknown";
64
67
  }
65
68
 
66
- function formatListeningTime(minutes) {
67
- if (!Number.isFinite(minutes) || minutes < 0) return "unknown duration";
68
- if (minutes < 120) return `${Math.round(minutes)} min`;
69
- return `${Math.round((minutes / 60) * 10) / 10} h`;
69
+ export function formatListeningTime(minutes, tr) {
70
+ if (!Number.isFinite(minutes) || minutes < 0) return tr("unknown duration");
71
+ if (minutes < 120) return tr("{minutes} min", { minutes: Math.round(minutes) });
72
+ return tr("{hours} h", { hours: tr.number(Math.round((minutes / 60) * 10) / 10) });
70
73
  }
71
74
 
72
- function plural(value, one, many = `${one}s`) {
73
- return `${formatInteger(value)} ${value === 1 ? one : many}`;
74
- }
75
+ function plays(value, tr) { return tr.n(value, "{count} play", "{count} plays"); }
76
+ function tracks(value, tr) { return tr.n(value, "{count} track", "{count} tracks"); }
75
77
 
76
- function formatArtistSignal(item) {
78
+ function formatArtistSignal(item, tr) {
77
79
  const details = [];
78
- if (Number.isInteger(item.play_count)) {
79
- details.push(plural(item.play_count, "play"));
80
- }
81
- if (Number.isFinite(item.listening_minutes)) {
82
- details.push(formatListeningTime(item.listening_minutes));
83
- }
84
- if (Number.isInteger(item.distinct_tracks)) {
85
- details.push(plural(item.distinct_tracks, "track"));
86
- }
87
- return `- ${safeInlineText(item.name)}${
88
- details.length > 0 ? ` · ${details.join(", ")}` : ""
89
- }`;
80
+ if (Number.isInteger(item.play_count)) details.push(plays(item.play_count, tr));
81
+ if (Number.isFinite(item.listening_minutes)) details.push(formatListeningTime(item.listening_minutes, tr));
82
+ if (Number.isInteger(item.distinct_tracks)) details.push(tracks(item.distinct_tracks, tr));
83
+ return `- ${safeInlineText(item.name)}${details.length > 0 ? ` · ${details.join(", ")}` : ""}`;
90
84
  }
91
85
 
92
- function formatTrackSignal(item) {
86
+ function formatTrackSignal(item, tr) {
93
87
  const label = safeInlineText(item.label);
94
88
  const artist = safeInlineText(item.artist_credit);
95
89
  const details = [];
96
- if (Number.isInteger(item.play_count)) {
97
- details.push(plural(item.play_count, "play"));
98
- }
99
- if (Number.isFinite(item.listening_minutes)) {
100
- details.push(formatListeningTime(item.listening_minutes));
101
- }
102
- return `- ${label}${artist ? ` - ${artist}` : ""}${
103
- details.length > 0 ? ` · ${details.join(", ")}` : ""
104
- }`;
90
+ if (Number.isInteger(item.play_count)) details.push(plays(item.play_count, tr));
91
+ if (Number.isFinite(item.listening_minutes)) details.push(formatListeningTime(item.listening_minutes, tr));
92
+ return `- ${label}${artist ? ` - ${artist}` : ""}${details.length > 0 ? ` · ${details.join(", ")}` : ""}`;
105
93
  }
106
94
 
107
95
  // Attention alone is the default reading, so only stronger evidence is worth naming.
108
96
  const signalPhrases = {
109
- "historical attention only": null,
110
- "saved-library state": "saved in your library",
111
- "private playlist curation": "on one of your playlists",
112
- "explicit listener preference": "you said you like it",
97
+ "historical attention only": () => null,
98
+ "saved-library state": tr => tr("saved in your library"),
99
+ "private playlist curation": tr => tr("on one of your playlists"),
100
+ "explicit listener preference": tr => tr("you said you like it"),
113
101
  };
114
102
 
115
- function signalPhrase(value) {
103
+ function signalPhrase(value, tr) {
116
104
  const text = safeInlineText(value);
117
105
  if (!text) return null;
118
- return Object.hasOwn(signalPhrases, text) ? signalPhrases[text] : text;
106
+ return Object.hasOwn(signalPhrases, text) ? signalPhrases[text](tr) : text;
119
107
  }
120
108
 
121
109
  function withDetails(base, details) {
@@ -123,105 +111,80 @@ function withDetails(base, details) {
123
111
  return `${base}${present.length > 0 ? ` · ${present.join(", ")}` : ""}`;
124
112
  }
125
113
 
126
- function formatRediscoveryTrack(item) {
127
- return withDetails(formatTrackSignal(item), [
128
- Number.isInteger(item.quiet_days)
129
- ? `quiet for ${plural(item.quiet_days, "day")}`
130
- : null,
131
- Number.isInteger(item.peak_year) ? `biggest in ${item.peak_year}` : null,
132
- signalPhrase(item.rediscovery_signal),
114
+ function formatRediscoveryTrack(item, tr) {
115
+ return withDetails(formatTrackSignal(item, tr), [
116
+ Number.isInteger(item.quiet_days) ? tr.n(item.quiet_days, "quiet for {count} day", "quiet for {count} days") : null,
117
+ Number.isInteger(item.peak_year) ? tr("biggest in {year}", { year: item.peak_year }) : null,
118
+ signalPhrase(item.rediscovery_signal, tr),
133
119
  ]);
134
120
  }
135
121
 
136
- function formatHistoricalReturnTrack(item) {
137
- return withDetails(formatTrackSignal(item), [
122
+ function formatHistoricalReturnTrack(item, tr) {
123
+ return withDetails(formatTrackSignal(item, tr), [
138
124
  Number.isInteger(item.return_count)
139
- ? `came back ${item.return_count === 1 ? "once" : `${formatInteger(item.return_count)} times`}`
125
+ ? item.return_count === 1 ? tr("came back once") : tr.n(item.return_count, "came back {count} time", "came back {count} times")
140
126
  : null,
141
- Number.isInteger(item.longest_gap_days)
142
- ? `longest time away ${plural(item.longest_gap_days, "day")}`
143
- : null,
144
- signalPhrase(item.historical_return_signal),
127
+ Number.isInteger(item.longest_gap_days) ? tr.n(item.longest_gap_days, "longest time away {count} day", "longest time away {count} days") : null,
128
+ signalPhrase(item.historical_return_signal, tr),
145
129
  ]);
146
130
  }
147
131
 
148
- function formatBackToBackTrack(item) {
149
- return withDetails(formatTrackSignal(item), [
150
- Number.isInteger(item.maximum_consecutive_plays)
151
- ? `up to ${formatInteger(item.maximum_consecutive_plays)} in a row`
152
- : null,
153
- Number.isInteger(item.burst_count) && item.burst_count > 1
154
- ? `${formatInteger(item.burst_count)} separate runs`
155
- : null,
132
+ function formatBackToBackTrack(item, tr) {
133
+ return withDetails(formatTrackSignal(item, tr), [
134
+ Number.isInteger(item.maximum_consecutive_plays) ? tr("up to {count} in a row", { count: tr.number(item.maximum_consecutive_plays) }) : null,
135
+ Number.isInteger(item.burst_count) && item.burst_count > 1 ? tr("{count} separate runs", { count: tr.number(item.burst_count) }) : null,
156
136
  ]);
157
137
  }
158
138
 
159
- function formatTimeCapsuleTrack(item) {
139
+ function formatTimeCapsuleTrack(item, tr) {
160
140
  const label = safeInlineText(item.label);
161
141
  const artist = safeInlineText(item.artist_credit);
162
142
  return withDetails(
163
- `- ${Number.isInteger(item.capsule_year) ? item.capsule_year : "Unknown year"} ${label}${artist ? ` - ${artist}` : ""}`,
143
+ `- ${Number.isInteger(item.capsule_year) ? item.capsule_year : tr("Unknown year")} ${label}${artist ? ` - ${artist}` : ""}`,
164
144
  [
165
- Number.isInteger(item.year_play_count)
166
- ? `${plural(item.year_play_count, "play")} that year`
167
- : null,
168
- signalPhrase(item.representative_signal),
145
+ Number.isInteger(item.year_play_count) ? tr.n(item.year_play_count, "{count} play that year", "{count} plays that year") : null,
146
+ signalPhrase(item.representative_signal, tr),
169
147
  ],
170
148
  );
171
149
  }
172
150
 
173
- function formatHistoryArc(item) {
151
+ function formatHistoryArc(item, tr) {
174
152
  const details = [];
175
- if (Number.isFinite(item.listening_minutes)) {
176
- details.push(formatListeningTime(item.listening_minutes));
177
- }
178
- if (Number.isInteger(item.event_count)) {
179
- details.push(plural(item.event_count, "play"));
180
- }
181
- if (Number.isInteger(item.distinct_tracks)) {
182
- details.push(plural(item.distinct_tracks, "track"));
183
- }
153
+ if (Number.isFinite(item.listening_minutes)) details.push(formatListeningTime(item.listening_minutes, tr));
154
+ if (Number.isInteger(item.event_count)) details.push(plays(item.event_count, tr));
155
+ if (Number.isInteger(item.distinct_tracks)) details.push(tracks(item.distinct_tracks, tr));
184
156
  if (Number.isInteger(item.first_observed_tracks) && item.first_observed_tracks > 0) {
185
- details.push(`${formatInteger(item.first_observed_tracks)} new`);
157
+ details.push(tr("{count} new", { count: tr.number(item.first_observed_tracks) }));
186
158
  }
187
159
  const topArtist = safeInlineText(item.top_artist?.name);
188
- if (topArtist) details.push(`mostly ${topArtist}`);
189
- return `- ${Number.isInteger(item.year) ? item.year : "Unknown year"}${
190
- details.length > 0 ? ` · ${details.join(", ")}` : ""
191
- }`;
160
+ if (topArtist) details.push(tr("mostly {artist}", { artist: topArtist }));
161
+ return `- ${Number.isInteger(item.year) ? item.year : tr("Unknown year")}${details.length > 0 ? ` · ${details.join(", ")}` : ""}`;
192
162
  }
193
163
 
194
- function formatListeningSeason(item) {
164
+ function formatListeningSeason(item, tr) {
195
165
  const key = safeInlineText(item.key).replace("-", " ");
196
166
  if (!Number.isInteger(item.event_count) || item.event_count === 0) {
197
- return `- ${key || "Unknown season"} · nothing in your history`;
167
+ return `- ${key || tr("Unknown season")} · ${tr("nothing in your history")}`;
198
168
  }
199
169
  const signature = safeInlineText(item.signature_track?.label);
200
170
  const signatureArtist = safeInlineText(item.signature_track?.artist_credit);
201
- return withDetails(`- ${key || "Unknown season"}`, [
202
- Number.isFinite(item.listening_minutes)
203
- ? formatListeningTime(item.listening_minutes)
204
- : null,
205
- plural(item.event_count, "play"),
171
+ return withDetails(`- ${key || tr("Unknown season")}`, [
172
+ Number.isFinite(item.listening_minutes) ? formatListeningTime(item.listening_minutes, tr) : null,
173
+ plays(item.event_count, tr),
206
174
  Number.isInteger(item.first_observed_tracks) && item.first_observed_tracks > 0
207
- ? `${formatInteger(item.first_observed_tracks)} new`
208
- : null,
209
- safeInlineText(item.leading_artist?.name)
210
- ? `mostly ${safeInlineText(item.leading_artist.name)}`
211
- : null,
212
- signature
213
- ? `top song ${signature}${signatureArtist ? ` - ${signatureArtist}` : ""}`
214
- : null,
175
+ ? tr("{count} new", { count: tr.number(item.first_observed_tracks) }) : null,
176
+ safeInlineText(item.leading_artist?.name) ? tr("mostly {artist}", { artist: safeInlineText(item.leading_artist.name) }) : null,
177
+ signature ? tr("top song {song}", { song: `${signature}${signatureArtist ? ` - ${signatureArtist}` : ""}` }) : null,
215
178
  ]);
216
179
  }
217
180
 
218
- function formatListenerAssertion(item) {
181
+ function formatListenerAssertion(item, tr) {
219
182
  const label = safeInlineText(item.label);
220
183
  const artist = safeInlineText(item.artist_credit);
221
184
  const target = artist ? `${label} - ${artist}` : label;
222
185
  const note = safeInlineText(item.note);
223
- const entity = safeInlineText(item.entity_type);
224
- return `- ${item.stance === "avoid" ? "Keep out" : "Like"}${entity ? ` (${entity})` : ""}: ${target}${
186
+ const entity = item.entity_type === "track" ? tr("track") : item.entity_type === "artist" ? tr("artist") : safeInlineText(item.entity_type);
187
+ return `- ${item.stance === "avoid" ? tr("Keep out") : tr("Like")}${entity ? ` (${entity})` : ""}: ${target}${
225
188
  note ? ` · "${note}"` : ""
226
189
  } · ${safeInlineText(item.correction_id)}`;
227
190
  }
@@ -231,246 +194,167 @@ function appendTasteSection(lines, title, values, formatter, subtitle) {
231
194
  lines.push("", `## ${title}`, "", ...(subtitle ? [`*${subtitle}*`, ""] : []), ...values.map(formatter));
232
195
  }
233
196
 
234
- function formatBehaviorContextLine(value, total, label) {
197
+ function formatBehaviorContextLine(value, total, label, tr) {
235
198
  if (!Number.isInteger(value) || total === 0) return null;
236
199
  const validTotal = Number.isInteger(total) && total >= value && total > 0;
237
- if (!validTotal) return `- ${label}: ${formatInteger(value)}`;
200
+ if (!validTotal) return `- ${label}: ${tr.number(value)}`;
238
201
  const share = Math.round((value / total) * 100);
239
- return `- ${label}: ${share}% (${formatInteger(value)} of ${formatInteger(total)})`;
202
+ return `- ${label}: ${tr("{share}% ({count} of {total})", { share, count: tr.number(value), total: tr.number(total) })}`;
240
203
  }
241
204
 
242
- function formatTaste(profile) {
205
+ // Section names are Pink Floyd titles and stay as they are; their subtitles are translated.
206
+ function formatTaste(profile, tr) {
243
207
  const coverage = profile.coverage ?? {};
244
208
  const durationUnavailable = coverage.events_with_played_duration === 0;
245
- const listeningSignal = (formatter) => (item) => formatter(durationUnavailable ? { ...item, listening_minutes: undefined } : item);
209
+ const listeningSignal = (formatter) => (item) => formatter(durationUnavailable ? { ...item, listening_minutes: undefined } : item, tr);
210
+ const signal = (formatter) => (item) => formatter(item, tr);
246
211
  const behavior = profile.listening_behavior ?? {};
247
212
  const context = behavior.context ?? {};
248
213
  const listeningRange = profile.listening_source?.listening_range;
249
214
  const range =
250
215
  typeof listeningRange?.earliest === "string" &&
251
216
  typeof listeningRange?.latest === "string"
252
- ? `${listeningRange.earliest.slice(0, 10)} to ${listeningRange.latest.slice(0, 10)}`
217
+ ? tr("{first} to {last}", { first: listeningRange.earliest.slice(0, 10), last: listeningRange.latest.slice(0, 10) })
253
218
  : null;
254
219
  const oneOff = profile.preview_source?.persistent_import === false;
255
220
  const hours = !durationUnavailable && Number.isFinite(coverage.listening_hours)
256
- ? `${coverage.listening_hours.toLocaleString("en-US")} h`
221
+ ? tr("{hours} h", { hours: tr.number(coverage.listening_hours) })
257
222
  : null;
258
223
  const lines = [
259
- "# Your listening, so far",
224
+ `# ${tr("Your listening, so far")}`,
260
225
  "",
261
226
  oneOff
262
- ? "A one-off reading of the file you chose. Nothing was saved to your profile."
263
- : "Read from your own files. It stays on this machine.",
227
+ ? tr("A one-off reading of the file you chose. Nothing was saved to your profile.")
228
+ : tr("Read from your own files. It stays on this machine."),
264
229
  "",
265
- "## What I'm reading from",
230
+ `## ${tr("What I'm reading from")}`,
266
231
  "",
267
232
  ];
268
233
  const summary = [
269
- Number.isInteger(coverage.effective_listening_events) ? plural(coverage.effective_listening_events, "play") : null,
234
+ Number.isInteger(coverage.effective_listening_events) ? plays(coverage.effective_listening_events, tr) : null,
270
235
  hours,
271
- Number.isInteger(coverage.listening_tracks) ? plural(coverage.listening_tracks, "track") : null,
236
+ Number.isInteger(coverage.listening_tracks) ? tracks(coverage.listening_tracks, tr) : null,
272
237
  ].filter(Boolean);
273
238
  if (summary.length > 0) lines.push(`- ${summary.join(" · ")}`);
274
239
  if (range) lines.push(`- ${range}`);
275
240
  const saved = coverage.saved_tracks ?? coverage.spotify_saved_tracks;
276
- if (Number.isInteger(saved) && saved > 0) lines.push(`- ${plural(saved, "saved track")}`);
241
+ if (Number.isInteger(saved) && saved > 0) lines.push(`- ${tr.n(saved, "{count} saved track", "{count} saved tracks")}`);
277
242
  const memberships = coverage.playlist_memberships ?? coverage.spotify_playlist_memberships;
278
- if (Number.isInteger(memberships) && memberships > 0) lines.push(`- ${plural(memberships, "song", "songs")} on your playlists`);
243
+ if (Number.isInteger(memberships) && memberships > 0) lines.push(`- ${tr.n(memberships, "{count} song on your playlists", "{count} songs on your playlists")}`);
279
244
  for (const source of coverage.collection_sources ?? []) {
280
- if (source.tracks > 0) lines.push(`- ${safeInlineText(source.label)}: ${plural(source.tracks, "track")}`);
245
+ if (source.tracks > 0) lines.push(`- ${safeInlineText(source.label)}: ${tracks(source.tracks, tr)}`);
281
246
  }
282
247
  if (!durationUnavailable && Number.isInteger(coverage.events_with_played_duration) &&
283
248
  Number.isInteger(coverage.effective_listening_events) &&
284
249
  coverage.events_with_played_duration < coverage.effective_listening_events) {
285
- lines.push(`- Listening time is known for ${formatInteger(coverage.events_with_played_duration)} of those plays`);
250
+ lines.push(`- ${tr("Listening time is known for {count} of those plays", { count: tr.number(coverage.events_with_played_duration) })}`);
286
251
  }
287
252
  if (Number.isInteger(coverage.cross_format_track_links) && coverage.cross_format_track_links > 0) {
288
- lines.push(`- ${plural(coverage.cross_format_track_links, "track")} matched across your two Spotify exports`);
253
+ lines.push(`- ${tr.n(coverage.cross_format_track_links, "{count} track matched across your two Spotify exports", "{count} tracks matched across your two Spotify exports")}`);
289
254
  }
290
255
  if (Number.isInteger(coverage.cross_format_ambiguous_tracks) && coverage.cross_format_ambiguous_tracks > 0) {
291
- lines.push(`- ${plural(coverage.cross_format_ambiguous_tracks, "track")} kept apart because the match was unclear`);
256
+ lines.push(`- ${tr.n(coverage.cross_format_ambiguous_tracks, "{count} track kept apart because the match was unclear", "{count} tracks kept apart because the match was unclear")}`);
292
257
  }
293
258
  if (Number.isInteger(context.incognito_events_excluded) && context.incognito_events_excluded > 0) {
294
- lines.push(`- ${plural(context.incognito_events_excluded, "private-session play")} left out`);
259
+ lines.push(`- ${tr.n(context.incognito_events_excluded, "{count} private-session play left out", "{count} private-session plays left out")}`);
295
260
  }
296
261
 
297
- appendTasteSection(
298
- lines,
299
- "What you told me",
300
- profile.listener_assertions?.active,
301
- formatListenerAssertion,
302
- );
303
- appendTasteSection(
304
- lines,
305
- "Shine On",
306
- behavior.enduring_artists,
307
- listeningSignal(formatArtistSignal),
308
- "Artists who stayed with you across the years",
309
- );
310
- appendTasteSection(
311
- lines,
312
- "Lately",
313
- behavior.recent_artists,
314
- listeningSignal(formatArtistSignal),
262
+ appendTasteSection(lines, tr("What you told me"), profile.listener_assertions?.active, signal(formatListenerAssertion));
263
+ appendTasteSection(lines, "Shine On", behavior.enduring_artists, listeningSignal(formatArtistSignal),
264
+ tr("Artists who stayed with you across the years"));
265
+ appendTasteSection(lines, tr("Lately"), behavior.recent_artists, listeningSignal(formatArtistSignal),
315
266
  Number.isInteger(context.recent_window_days)
316
- ? `Who you've played most in the last ${context.recent_window_days} days`
317
- : "Who you've played most recently",
318
- );
319
- appendTasteSection(
320
- lines,
321
- "Year by year",
322
- behavior.history_arc,
323
- listeningSignal(formatHistoryArc),
324
- );
267
+ ? tr("Who you've played most in the last {count} days", { count: context.recent_window_days })
268
+ : tr("Who you've played most recently"));
269
+ appendTasteSection(lines, tr("Year by year"), behavior.history_arc, listeningSignal(formatHistoryArc));
325
270
  const listeningSeasons = behavior.listening_seasons?.seasons;
326
- appendTasteSection(
327
- lines,
328
- "Seasons",
329
- Array.isArray(listeningSeasons) ? listeningSeasons.slice(-12) : [],
271
+ appendTasteSection(lines, tr("Seasons"), Array.isArray(listeningSeasons) ? listeningSeasons.slice(-12) : [],
330
272
  listeningSignal(formatListeningSeason),
331
- "Your last twelve quarters. \"New\" means new to your history, not necessarily new to you.",
332
- );
333
- appendTasteSection(
334
- lines,
335
- "Time",
336
- behavior.time_capsule_tracks,
337
- formatTimeCapsuleTrack,
338
- "The years, one track each",
339
- );
340
- appendTasteSection(
341
- lines,
342
- "Wish You Were Here",
343
- behavior.rediscovery_tracks,
344
- formatRediscoveryTrack,
273
+ tr("Your last twelve quarters. \"New\" means new to your history, not necessarily new to you."));
274
+ appendTasteSection(lines, "Time", behavior.time_capsule_tracks, signal(formatTimeCapsuleTrack), tr("The years, one track each"));
275
+ appendTasteSection(lines, "Wish You Were Here", behavior.rediscovery_tracks, signal(formatRediscoveryTrack),
345
276
  Number.isInteger(context.rediscovery_quiet_days)
346
- ? `Songs you used to play a lot that have gone quiet for ${context.rediscovery_quiet_days}+ days`
347
- : "Songs you used to play a lot that have gone quiet",
348
- );
349
- appendTasteSection(
350
- lines,
351
- "Coming Back to Life",
352
- behavior.historical_return_tracks,
353
- formatHistoricalReturnTrack,
277
+ ? tr("Songs you used to play a lot that have gone quiet for {count}+ days", { count: context.rediscovery_quiet_days })
278
+ : tr("Songs you used to play a lot that have gone quiet"));
279
+ appendTasteSection(lines, "Coming Back to Life", behavior.historical_return_tracks, signal(formatHistoricalReturnTrack),
354
280
  Number.isInteger(context.historical_return_minimum_gap_days)
355
- ? `Songs that found their way back after ${context.historical_return_minimum_gap_days}+ days away`
356
- : "Songs that found their way back after long gaps",
357
- );
358
- appendTasteSection(
359
- lines,
360
- "Echoes",
361
- behavior.back_to_back_tracks,
362
- formatBackToBackTrack,
363
- "Songs you played again right away",
364
- );
365
- appendTasteSection(
366
- lines,
367
- "Most played",
368
- behavior.repeat_tracks,
369
- listeningSignal(formatTrackSignal),
370
- );
371
- appendTasteSection(
372
- lines,
373
- "Recently",
374
- behavior.recent_tracks,
375
- listeningSignal(formatTrackSignal),
376
- );
281
+ ? tr("Songs that found their way back after {count}+ days away", { count: context.historical_return_minimum_gap_days })
282
+ : tr("Songs that found their way back after long gaps"));
283
+ appendTasteSection(lines, "Echoes", behavior.back_to_back_tracks, signal(formatBackToBackTrack), tr("Songs you played again right away"));
284
+ appendTasteSection(lines, tr("Most played"), behavior.repeat_tracks, listeningSignal(formatTrackSignal));
285
+ appendTasteSection(lines, tr("Recently"), behavior.recent_tracks, listeningSignal(formatTrackSignal));
377
286
 
378
287
  const behaviorContextLines = [
379
- formatBehaviorContextLine(context.direct_selection_starts, context.start_reason_events, "Songs you picked yourself"),
380
- formatBehaviorContextLine(context.trackdone_starts, context.start_reason_events, "Songs that followed on"),
381
- formatBehaviorContextLine(context.trackdone_endings, context.end_reason_events, "Played to the end"),
382
- formatBehaviorContextLine(context.explicit_skips, context.skip_state_events, "Skipped"),
383
- formatBehaviorContextLine(context.shuffle_events, context.shuffle_state_events, "On shuffle"),
384
- formatBehaviorContextLine(context.offline_events, context.offline_state_events, "Offline"),
288
+ formatBehaviorContextLine(context.direct_selection_starts, context.start_reason_events, tr("Songs you picked yourself"), tr),
289
+ formatBehaviorContextLine(context.trackdone_starts, context.start_reason_events, tr("Songs that followed on"), tr),
290
+ formatBehaviorContextLine(context.trackdone_endings, context.end_reason_events, tr("Played to the end"), tr),
291
+ formatBehaviorContextLine(context.explicit_skips, context.skip_state_events, tr("Skipped"), tr),
292
+ formatBehaviorContextLine(context.shuffle_events, context.shuffle_state_events, tr("On shuffle"), tr),
293
+ formatBehaviorContextLine(context.offline_events, context.offline_state_events, tr("Offline"), tr),
385
294
  ].filter(Boolean);
386
295
  if (behaviorContextLines.length > 0) {
387
- lines.push("", "## How you listen", "", ...behaviorContextLines);
296
+ lines.push("", `## ${tr("How you listen")}`, "", ...behaviorContextLines);
388
297
  }
389
298
 
390
- if (
391
- Array.isArray(profile.strong_preferences) &&
392
- profile.strong_preferences.length > 0
393
- ) {
394
- lines.push(
395
- "",
396
- "## Kept on purpose",
397
- "",
398
- ...profile.strong_preferences.map(
399
- (item) =>
400
- `- ${safeInlineText(item.label)} · ${safeInlineText(item.signal).toLowerCase()}`,
401
- ),
402
- );
299
+ if (Array.isArray(profile.strong_preferences) && profile.strong_preferences.length > 0) {
300
+ lines.push("", `## ${tr("Kept on purpose")}`, "",
301
+ ...profile.strong_preferences.map((item) => `- ${safeInlineText(item.label)} · ${safeInlineText(item.signal).toLowerCase()}`));
403
302
  }
404
303
 
405
304
  lines.push(
406
305
  "",
407
306
  "## The dark side of the moon",
408
307
  "",
409
- "*What this reading can't see*",
308
+ `*${tr("What this reading can't see")}*`,
410
309
  "",
411
- "- Plays show attention, not love. A song can be on repeat because it was stuck in your head.",
412
- ...(behaviorContextLines.length > 0 ? ["- A skip is a moment, not a verdict."] : []),
310
+ `- ${tr("Plays show attention, not love. A song can be on repeat because it was stuck in your head.")}`,
311
+ ...(behaviorContextLines.length > 0 ? [`- ${tr("A skip is a moment, not a verdict.")}`] : []),
413
312
  ...((behavior.history_arc ?? []).length > 0 || (listeningSeasons ?? []).length > 0
414
- ? ["- Years and seasons follow UTC, so a late night can land on the next day.",
415
- "- A quiet stretch means no history was kept, not that you stopped listening."]
313
+ ? [`- ${tr("Years and seasons follow UTC, so a late night can land on the next day.")}`,
314
+ `- ${tr("A quiet stretch means no history was kept, not that you stopped listening.")}`]
416
315
  : []),
417
- "- If something here is wrong, open the song or artist in /taste and tell me.",
316
+ `- ${tr("If something here is wrong, open the song or artist in /taste and tell me.")}`,
418
317
  "",
419
318
  oneOff
420
- ? "Add `--json` to the same `--from` command for the full data."
421
- : "For the full data with evidence IDs, run `moondog taste --json`.",
319
+ ? tr("Add `--json` to the same `--from` command for the full data.")
320
+ : tr("For the full data with evidence IDs, run `moondog taste --json`."),
422
321
  );
423
322
  return lines.join("\n");
424
323
  }
425
324
 
426
- function formatMemory(memory) {
325
+ function formatMemory(memory, tr) {
427
326
  const lines = [];
428
327
  if (Number.isInteger(memory.sessions)) {
429
328
  lines.push(
430
- `- ${formatInteger(memory.sessions)} ${memory.sessions === 1 ? "conversation" : "conversations"} saved on this machine`,
431
- `- ${formatInteger(memory.active_memories)} ${memory.active_memories === 1 ? "thing" : "things"} I remember about you`,
329
+ `- ${tr.n(memory.sessions, "{count} conversation saved on this machine", "{count} conversations saved on this machine")}`,
330
+ `- ${tr.n(memory.active_memories, "{count} thing I remember about you", "{count} things I remember about you")}`,
432
331
  );
433
332
  if (memory.reflection?.pending_episodes > 0) {
434
- lines.push(`- ${formatInteger(memory.reflection.pending_episodes)} recent moments I haven't thought over yet`);
333
+ lines.push(`- ${tr.n(memory.reflection.pending_episodes, "{count} recent moment I haven't thought over yet", "{count} recent moments I haven't thought over yet")}`);
435
334
  }
436
335
  } else {
437
336
  lines.push(`- ${words(memory.state)}`);
438
337
  }
439
338
  lines.push(
440
339
  "",
441
- "I keep what you tell me in conversation apart from your music profile.",
442
- "I only remember something for good when you say it plainly, or when you ask with `/remember`.",
443
- "`/forget <id>` removes it.",
340
+ tr("I keep what you tell me in conversation apart from your music profile."),
341
+ tr("I only remember something for good when you say it plainly, or when you ask with `/remember`."),
342
+ tr("`/forget <id>` removes it."),
444
343
  );
445
344
  if (Array.isArray(memory.memories) && memory.memories.length > 0) {
446
- lines.push(
447
- "",
448
- "## What I remember",
449
- "",
450
- ...memory.memories.map((entry) => `- ${entry.text} · ${entry.kind} · \`${entry.memory_id}\``),
451
- );
345
+ lines.push("", `## ${tr("What I remember")}`, "",
346
+ ...memory.memories.map((entry) => `- ${entry.text} · ${entry.kind} · \`${entry.memory_id}\``));
452
347
  }
453
348
  if (Array.isArray(memory.recent_episodes) && memory.recent_episodes.length > 0) {
454
- lines.push(
455
- "",
456
- "## Lately",
457
- "",
458
- ...memory.recent_episodes.map((episode) => `- ${String(episode.occurred_at).slice(0, 10)} · ${episode.summary}`),
459
- );
349
+ lines.push("", `## ${tr("Lately")}`, "",
350
+ ...memory.recent_episodes.map((episode) => `- ${String(episode.occurred_at).slice(0, 10)} · ${episode.summary}`));
460
351
  }
461
352
  if (Array.isArray(memory.recent_sessions) && memory.recent_sessions.length > 0) {
462
- lines.push(
463
- "",
464
- "## Recent conversations",
465
- "",
353
+ lines.push("", `## ${tr("Recent conversations")}`, "",
466
354
  ...memory.recent_sessions.map((session) => {
467
- const userTurns = session.turns
468
- .filter((turn) => turn.role === "user")
469
- .map((turn) => turn.text)
470
- .join(" / ");
355
+ const userTurns = session.turns.filter((turn) => turn.role === "user").map((turn) => turn.text).join(" / ");
471
356
  return `- ${String(session.started_at).slice(0, 10)} · ${userTurns}`;
472
- }),
473
- );
357
+ }));
474
358
  }
475
359
  return lines.join("\n");
476
360
  }
@@ -566,185 +450,112 @@ export function formatDemoResult(value) {
566
450
  }
567
451
 
568
452
  const capabilityStates = {
569
- ready: "ready",
570
- enabled: "ready",
571
- available: "ready",
572
- blocked: "not available yet",
573
- unavailable: "not available",
574
- disabled: "off",
453
+ ready: tr => tr("ready"),
454
+ enabled: tr => tr("ready"),
455
+ available: tr => tr("ready"),
456
+ blocked: tr => tr("not available yet"),
457
+ unavailable: tr => tr("not available"),
458
+ disabled: tr => tr("off"),
575
459
  };
576
460
 
577
- function formatTools(value) {
461
+ function formatTools(value, tr) {
578
462
  const rows = value.capabilities.map((capability) => {
579
- const label = capability.agent_tool?.label ?? capability.id;
580
- const state = capabilityStates[capability.state] ?? words(capability.state);
463
+ const label = capability.agent_tool?.label ? tr.marked(capability.agent_tool.label) : capability.id;
464
+ const state = capabilityStates[capability.state]?.(tr) ?? words(capability.state);
581
465
  return `- **${label}** · ${state}\n ${capability.description}${
582
- capability.blocked_by ? `\n Waiting on: ${words(capability.blocked_by)}` : ""
466
+ capability.blocked_by ? `\n ${tr("Waiting on: {reason}", { reason: words(capability.blocked_by) })}` : ""
583
467
  }`;
584
468
  });
585
- return `# What I can do\n\n${rows.join("\n")}`;
469
+ return `# ${tr("What I can do")}\n\n${rows.join("\n")}`;
586
470
  }
587
471
 
588
472
  const doctorLabels = {
589
- "web.codex_cli": "Web lookups (Codex CLI)",
473
+ "web.search": N_("Web search"),
474
+ "web.read": N_("Web page reading"),
590
475
  "node.version": "Node.js",
591
- "apple_music.source": "Apple Music library",
592
- "profile.projection": "Listening profile",
593
- "agent.runtime": "Model",
594
- "music.discovery.open_similarity": "Finding similar music",
595
- "external.effects": "Changes to your accounts",
476
+ "apple_music.source": N_("Apple Music library"),
477
+ "profile.projection": N_("Listening profile"),
478
+ "agent.runtime": N_("Model"),
479
+ "music.discovery.open_similarity": N_("Finding similar music"),
480
+ "external.effects": N_("Changes to your accounts"),
596
481
  "spotify.connection": "Spotify",
597
- "spotify.connected_action_scopes": "Spotify permissions",
482
+ "spotify.connected_action_scopes": N_("Spotify permissions"),
598
483
  };
599
484
 
600
- function formatDoctor(value) {
485
+ function formatDoctor(value, tr) {
601
486
  return [
602
- "# Checkup",
487
+ `# ${tr("Checkup")}`,
603
488
  "",
604
489
  ...value.checks.map((check) => {
605
- const state = check.ok ? "ok" : check.optional_for_local_commands ? "not set up (optional)" : "not working";
490
+ const state = check.ok ? tr("ok") : check.optional_for_local_commands ? tr("not set up (optional)") : tr("not working");
606
491
  const detail = words(check.actual ?? check.state ?? "unknown");
607
- return `- ${doctorLabels[check.id] ?? check.id}: ${state} · ${detail}`;
492
+ const label = doctorLabels[check.id] ? tr.marked(doctorLabels[check.id]) : check.id;
493
+ return `- ${label}: ${state} · ${detail}`;
608
494
  }),
609
495
  ].join("\n");
610
496
  }
611
497
 
612
- function formatRuntime(runtime) {
498
+ function formatRuntime(runtime, tr) {
613
499
  if (runtime?.state === "configured") {
614
- return `- Talking through ${runtime.provider && runtime.model ? `\`${runtime.provider}/${runtime.model}\`` : "your chosen model"}`;
500
+ return `- ${runtime.provider && runtime.model ? tr("Talking through `{model}`", { model: `${runtime.provider}/${runtime.model}` }) : tr("Talking through your chosen model")}`;
615
501
  }
616
502
  return runtime?.reason === "provider_authentication_required"
617
- ? "- Not signed in yet. `/auth` fixes that."
618
- : "- No model connected. `/model` picks one. Your profile works without it.";
503
+ ? `- ${tr("Not signed in yet. `/auth` fixes that.")}`
504
+ : `- ${tr("No model connected. `/model` picks one. Your profile works without it.")}`;
619
505
  }
620
506
 
621
- function formatSpotifyStatus(spotify) {
507
+ function formatSpotifyStatus(spotify, tr) {
622
508
  const state = spotify?.state ?? "not_configured";
623
- if (state === "ready" || state === "connected") return "- Connected";
509
+ if (state === "ready" || state === "connected") return `- ${tr("Connected")}`;
624
510
  return state === "not_configured"
625
- ? "- Not connected. `/import` > Spotify walks you through it."
511
+ ? `- ${tr("Not connected. `/import` > Spotify walks you through it.")}`
626
512
  : `- ${words(state)}`;
627
513
  }
628
514
 
629
- export function formatLocalResult(command, value) {
515
+ /** Local command output. The TUI passes its translator; the shell CLI uses English. */
516
+ export function formatLocalResult(command, value, tr = screenTranslator("en")) {
630
517
  switch (command) {
631
518
  case "status":
632
519
  case "sources":
633
520
  return [
634
- "# Where things stand",
521
+ `# ${tr("Where things stand")}`,
635
522
  "",
636
- "## Your profile",
523
+ `## ${tr("Your profile")}`,
637
524
  "",
638
- formatProfile(value.profile),
639
- formatSource(value.source),
525
+ formatProfile(value.profile, tr),
526
+ formatSource(value.source, tr),
640
527
  "",
641
- "## Talking",
528
+ `## ${tr("Talking")}`,
642
529
  "",
643
- formatRuntime(value.runtime),
530
+ formatRuntime(value.runtime, tr),
644
531
  "",
645
- "## Memory",
532
+ `## ${tr("Memory")}`,
646
533
  "",
647
- formatMemory(value.memory),
534
+ formatMemory(value.memory, tr),
648
535
  "",
649
536
  "## Spotify",
650
537
  "",
651
- formatSpotifyStatus(value.spotify),
538
+ formatSpotifyStatus(value.spotify, tr),
652
539
  "",
653
540
  value.external_effects === "disabled" && (value.spotify?.external_effects ?? "disabled") === "disabled"
654
- ? "Right now I can't change anything in your music accounts."
655
- : "I can make changes in your connected accounts, and I always ask first.",
541
+ ? tr("Right now I can't change anything in your music accounts.")
542
+ : tr("I can make changes in your connected accounts, and I always ask first."),
656
543
  ].join("\n");
657
544
  case "profile":
658
- return `# Your profile\n\n${formatProfile(value)}`;
545
+ return `# ${tr("Your profile")}\n\n${formatProfile(value, tr)}`;
659
546
  case "taste":
660
- return formatTaste(value);
547
+ return formatTaste(value, tr);
661
548
  case "memory":
662
- return `# What I remember\n\n${formatMemory(value)}`;
549
+ return `# ${tr("What I remember")}\n\n${formatMemory(value, tr)}`;
663
550
  case "tools":
664
- return formatTools(value);
551
+ return formatTools(value, tr);
665
552
  case "doctor":
666
- return formatDoctor(value);
553
+ return formatDoctor(value, tr);
667
554
  default:
668
555
  return JSON.stringify(value, null, 2);
669
556
  }
670
557
  }
671
558
 
672
- export function helpText() {
673
- return `# Every command
674
-
675
- From your shell:
676
-
677
- - \`moondog web status|search|read [arguments] [--json]\` - look things up on public music sites through the Codex CLI
678
- - \`moondog studio\` - open the browser Studio for dropping in a Spotify ZIP
679
- - \`moondog studio --demo\` - try the Studio with made-up history; your own data is never read
680
- - \`moondog studio --from <spotify-history.zip>\` - look at one Spotify ZIP in the Studio without saving it
681
- - \`moondog studio --from <account-data.zip> --from <extended-history.zip>\` - combine two Spotify ZIPs in the Studio without saving them
682
- - \`moondog demo-history --output <absolute-file.zip> [--json]\` - make a fictional Spotify history ZIP to try the importer with
683
- - \`moondog auth login <provider>\` - sign in to a model provider; the key is typed out of sight, and openai-codex uses your ChatGPT sign-in
684
- - \`moondog auth status [provider]\` - see which sign-ins are saved
685
- - \`moondog auth logout <provider>\` - sign out of a provider
686
- - \`moondog spotify help\` - set up and control Spotify
687
- - \`moondog spotify login [client-id]\` - connect Spotify in your browser
688
- - \`moondog spotify now|devices|queue\` - see what Spotify is playing, where, and what's next
689
- - \`moondog spotify recent\` - see what you played lately
690
- - \`moondog spotify sync-recent\` - add your latest plays to your profile
691
- - \`moondog spotify play|pause|next|previous\` - control Spotify playback
692
- - \`moondog listenbrainz import-history <listen-history.json>\` - add a ListenBrainz listens file to your profile
693
- - \`moondog catalog latest-single --artist <name> [--known-release <title> | --from <spotify-history.zip>]\` - find an artist's latest single on Apple Music (US), no model needed
694
- - \`moondog status [--json]\` - see where things stand
695
- - \`moondog taste [--json]\` - your listening report
696
- - \`moondog taste --html [--output <file.html>]\` - save your report as a web page that works offline
697
- - \`moondog taste --card [--output <file.html>]\` - save a short recap you can check before sharing
698
- - \`moondog taste --from <spotify-history.zip> --html\` - a one-off report from a Spotify ZIP, without saving it
699
- - \`moondog taste --from <account-data.zip> --from <extended-history.zip> --html\` - a one-off report from both Spotify exports together
700
- - \`moondog taste --from <spotify-history.zip> --save --html\` - save the Spotify history, then show your full report
701
- - \`moondog data inspect --scope <listening|apple|profile>\` - see what's stored, and get the code needed to reset it
702
- - \`moondog data export --scope <listening|apple|profile> --output <directory>\` - export a copy, leaving the original alone
703
- - \`moondog data reset --scope <listening|apple|profile> --confirm <token>\` - set stored data aside (it is archived, not deleted)
704
- - \`moondog profile corrections [--all]\` - everything you've told me, including earlier choices
705
- - \`moondog profile correct --artist <name> (--like|--avoid)\` - like an artist, or keep them out
706
- - \`moondog profile correct --track <title> --by <artist> (--like|--avoid)\` - like a track, or keep it out
707
- - \`moondog profile retract <correction-id>\` - undo one of your choices
708
- - \`moondog demo --offline [--json]\` - a demo with made-up data, no sign-in needed
709
- - \`moondog demo [prompt] [--json]\` - the same demo, talking through your model
710
- - \`moondog ask <prompt>\` - ask one question and exit
711
- - \`moondog remember <text>\` - ask me to remember something
712
- - \`moondog forget <memory-id>\` - forget something
713
- - \`moondog memory reflect [--dry-run] [--json]\` - let me think over recent conversations now
714
- - \`moondog\` - open the listening room
715
-
716
- Inside the listening room:
717
-
718
- - \`/status\` or \`/sources\` - see where things stand
719
- - \`/profile\` or \`/taste\` - your profile, and why each song or artist is there
720
- - \`/taste report\` - the whole report on one page
721
- - \`/profile corrections [--all]\` - everything you've told me
722
- - \`/profile correct --track "<title>" --by "<artist>" (--like|--avoid)\` - like a track, or keep it out
723
- - \`/profile correct --artist "<name>" (--like|--avoid)\` - like an artist, or keep them out
724
- - \`/profile retract <correction-id>\` - undo a choice
725
- - \`/memory\` - what I remember
726
- - \`/tools\` - what I can do right now
727
- - \`/doctor\` - check your setup
728
- - \`/model\` - pick the model I talk through
729
- - \`/model <provider> <model>\` - switch straight to a model
730
- - \`/auth [provider]\` - sign in to a model provider
731
- - \`/web status|search|read\` - look things up on public music sites
732
- - \`/import [path]\` - bring in your history, library, or a playlist
733
- - \`/spotify\` - Spotify commands
734
- - \`/spotify login [client-id]\` - connect Spotify
735
- - \`/spotify now|devices|queue\` - see what's playing, where, and what's next
736
- - \`/spotify recent|sync-recent\` - see or save your latest plays
737
- - \`/spotify import-history "/path/to/spotify-history.zip"\` - add a Spotify ZIP, no sign-in needed
738
- - \`/reload\` - reload model settings and sign-ins
739
- - \`/remember [kind] <text>\` - ask me to remember something
740
- - \`/forget <memory-id>\` - forget something
741
- - \`/resume\` - pick up a saved conversation
742
- - \`/resume <session-id>\` - go straight to one
743
- - \`/new\` - start fresh; the last one stays saved
744
- - \`/quit\` - exit
745
-
746
- Anything you type that isn't a command goes to the model you picked with \`/model\`, or the one set in \`MOONDOG_PROVIDER\` and \`MOONDOG_MODEL\`.
747
- Sign in with \`/auth\`, or set the provider's API key in your environment. \`/auth openai-codex\` uses your ChatGPT sign-in.
748
- Once your profile is ready, I can search your library, explain what I see, and plan playlists with you.
749
- I can only control Spotify after \`moondog spotify login\`, and I never post, send messages, delete anything, or spend money.`;
559
+ export function helpText(tr = screenTranslator("en")) {
560
+ return tr("# Every command\n\nFrom your shell:\n\n- `moondog --version` - show the installed version\n- `moondog update [--check] [--channel latest|beta]` - check for or install a published release\n- `moondog web status|search|read [arguments] [--json]` - look things up on public music sites through DeepSeek, Brave Search or the Codex CLI\n- `moondog demo-history --output <absolute-file.zip> [--json]` - make a fictional Spotify history ZIP to try the importer with\n- `moondog auth login <provider>` - sign in to a model provider; the key is typed out of sight, and openai-codex uses your ChatGPT sign-in\n- `moondog auth status [provider]` - see which sign-ins are saved\n- `moondog auth logout <provider>` - sign out of a provider\n- `moondog spotify help` - set up and control Spotify\n- `moondog spotify login [client-id]` - connect Spotify in your browser\n- `moondog spotify now|devices|queue` - see what Spotify is playing, where, and what's next\n- `moondog spotify recent` - see what you played lately\n- `moondog spotify sync-recent` - add your latest plays to your profile\n- `moondog spotify sync-library` - add your Spotify library to your profile\n- `moondog spotify play|pause|next|previous` - control Spotify playback\n- `moondog listenbrainz import-history <listen-history.json>` - add a ListenBrainz listens file to your profile\n- `moondog catalog latest-single --artist <name> [--known-release <title> | --from <spotify-history.zip>]` - find an artist's latest single on Apple Music (US), no model needed\n- `moondog status [--json]` - see where things stand\n- `moondog taste [--json]` - your listening report\n- `moondog taste --html [--output <file.html>]` - save your report as a web page that works offline\n- `moondog taste --card [--output <file.html>]` - save a short recap you can check before sharing\n- `moondog taste --from <spotify-history.zip> --html` - a one-off report from a Spotify ZIP, without saving it\n- `moondog taste --from <account-data.zip> --from <extended-history.zip> --html` - a one-off report from both Spotify exports together\n- `moondog taste --from <spotify-history.zip> --save --html` - save the Spotify history, then show your full report\n- `moondog data inspect --scope <listening|apple|profile>` - see what's stored, and get the code needed to reset it\n- `moondog data export --scope <listening|apple|profile> --output <directory>` - export a copy, leaving the original alone\n- `moondog data reset --scope <listening|apple|profile> --confirm <token>` - set stored data aside (it is archived, not deleted)\n- `moondog profile build [--force]` - build or resume a saved profile with your connected model\n- `moondog profile saved [offset]` - read your saved profile\n- `moondog profile explain <number>` - trace a saved finding to its evidence\n- `moondog profile corrections [--all]` - everything you've told me, including earlier choices\n- `moondog profile correct --artist <name> (--like|--avoid)` - like an artist, or keep them out\n- `moondog profile correct --track <title> --by <artist> (--like|--avoid)` - like a track, or keep it out\n- `moondog profile retract <correction-id>` - undo one of your choices\n- `moondog demo --offline [--json]` - a demo with made-up data, no sign-in needed\n- `moondog demo [prompt] [--json]` - the same demo, talking through your model\n- `moondog ask <prompt>` - ask one question and exit\n- `moondog remember <text>` - ask me to remember something\n- `moondog forget <memory-id>` - forget something\n- `moondog memory reflect [--dry-run] [--json]` - let me think over recent conversations now\n- `moondog` - open the listening room\n\nInside the listening room:\n\n- `/update [--check] [--channel latest|beta]` - check for updates, or save and close this session to install one\n- `/status` or `/sources` - see where things stand\n- `/profile` or `/taste` - your profile, and why each song or artist is there\n- `/taste report` - the whole report on one page\n- `/profile build [--force]` - build or resume a saved reading; Ctrl+C keeps progress\n- `/profile saved [offset]` - read your saved profile\n- `/profile explain <number>` - inspect a saved finding's evidence\n- `/profile corrections [--all]` - everything you've told me\n- `/profile correct --track \"<title>\" --by \"<artist>\" (--like|--avoid)` - like a track, or keep it out\n- `/profile correct --artist \"<name>\" (--like|--avoid)` - like an artist, or keep them out\n- `/profile retract <correction-id>` - undo a choice\n- `/memory` - what I remember\n- `/tools` - what I can do right now\n- `/doctor` - check your setup\n- `/model` - pick the model I talk through\n- `/model <provider> <model>` - switch straight to a model\n- `/model refresh [provider]` - update public model choices; keep the selected model\n- `/auth [provider]` - sign in to a model provider\n- `/web status|search|read` - look things up on public music sites\n- `/import [path]` - bring in your history, library, or a playlist\n- `/language` - choose the language Moondog uses\n- `/spotify` - Spotify commands\n- `/spotify login [client-id]` - connect Spotify\n- `/spotify now|devices|queue` - see what's playing, where, and what's next\n- `/spotify recent|sync-recent` - see or save your latest plays\n- `/spotify import-history \"/path/to/spotify-history.zip\"` - add a Spotify ZIP, no sign-in needed\n- `/skip` or `/stop` - move to the next song preview, or stop them\n- `/reload` - reload model settings and sign-ins\n- `/remember [kind] <text>` - ask me to remember something\n- `/forget <memory-id>` - forget something\n- `/rewind` or Alt+R - edit an earlier message on a new branch; the original stays saved\n- `/rewind latest` - edit the most recent message\n- `/resume` - pick up a saved conversation\n- `/resume <session-id>` - go straight to one\n- `/new` - start fresh; the last one stays saved\n- `/quit` - exit\n\nAnything you type that isn't a command goes to the model you picked with `/model`, or the one set in `MOONDOG_PROVIDER` and `MOONDOG_MODEL`.\nSign in with `/auth`, or set the provider's API key in your environment. `/auth openai-codex` uses your ChatGPT sign-in.\nOnce your profile is ready, I can search your library, explain what I see, and plan playlists with you.\nI can only control Spotify after `moondog spotify login`, and I never post, send messages, delete anything, or spend money.\nAsk me to move playback onto a device by name, such as your iPhone.");
750
561
  }