@audiofool/moondog 0.3.0-beta.6 → 0.3.0-beta.8

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.
@@ -0,0 +1,45 @@
1
+ // The host chooses the language of each reply (src/i18n messageLocale), and the model is told
2
+ // it. Some models still drift, for example into Chinese on a Spanish request. A reply clearly in
3
+ // another language is restated once by the same model, without tools, in the chosen language.
4
+ import { clampThinkingLevel } from "@earendil-works/pi-ai";
5
+ import { LOCALES, messageLocale } from "../../i18n/index.mjs";
6
+
7
+ const CJK_LOCALES = new Set(["zh", "zh-Hant", "ja", "ko"]);
8
+ const sameFamily = (left, right) => left === right || (left.startsWith("zh") && right.startsWith("zh"));
9
+
10
+ /** Whether a reply is clearly in another language than the one chosen for it. */
11
+ export function replyInOtherLanguage(text, locale) {
12
+ if (typeof text !== "string" || !text.trim() || !LOCALES[locale]) return false;
13
+ // Quoted and bracketed names travel across languages; in a Latin-script reply, so do Chinese
14
+ // characters (song, album and artist names).
15
+ const prose = text.replace(/"(?:[^"\\]|\\.)*"|“[^”]*”|«[^»]*»|「[^」]*」|『[^』]*』|《[^》]*》|\*\*[^*]*\*\*/gu, " ");
16
+ // A Chinese character stands for about one word. Fewer of them than Latin words are names;
17
+ // more of them are the reply itself.
18
+ const han = prose.match(/\p{Script=Han}/gu)?.length ?? 0;
19
+ const latin = prose.match(/\p{Script=Latin}+/gu)?.length ?? 0;
20
+ const names = !CJK_LOCALES.has(locale) && latin > han;
21
+ // The reply's own language; words that name a language ("I'll answer in Spanish") are not a request here.
22
+ const read = messageLocale(names ? prose.replace(/\p{Script=Han}+/gu, " ") : prose, locale, { explicit: false });
23
+ return !sameFamily(read, locale);
24
+ }
25
+
26
+ const SYSTEM_PROMPT = "You restate a music assistant's reply in another language. Keep its meaning, facts, tone, line breaks and Markdown. Keep names of songs, albums, artists and devices exactly as written. Return only the restated reply.";
27
+
28
+ /** The reply restated in the chosen language, or null when it needs none or the call fails. */
29
+ export async function restateInLanguage({ models, model, text, locale, signal, maxTokens = 2_000 }) {
30
+ if (!replyInOtherLanguage(text, locale) || typeof models?.completeSimple !== "function" || !model) return null;
31
+ const lightest = clampThinkingLevel(model, "off");
32
+ try {
33
+ const message = await models.completeSimple(model, {
34
+ systemPrompt: SYSTEM_PROMPT,
35
+ messages: [{ role: "user", content: `Restate this reply in ${LOCALES[locale].name} (${locale}).\n\n${text}`, timestamp: Date.now() }],
36
+ }, { maxTokens, maxRetries: 0, signal, ...(lightest !== "off" ? { reasoning: lightest } : {}) });
37
+ // Only a restatement that ended on its own replaces the reply; one cut at the token limit would lose its end.
38
+ if (!message || message.stopReason !== "stop") return null;
39
+ const restated = (message.content ?? []).filter((block) => block.type === "text").map((block) => block.text).join("").trim();
40
+ // A restatement still in the wrong language is no better than the reply it replaces.
41
+ return restated && !replyInOtherLanguage(restated, locale) ? restated : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
@@ -0,0 +1,89 @@
1
+ // Weaker models often get a tool call almost right: "play" for "resume", one song where a list is
2
+ // asked for, an empty field, an extra key the tool does not take. The host repairs only mistakes
3
+ // with one clear reading, before Pi validates the call, so the call runs as the model meant it.
4
+ // Anything else is left as it was, and Pi's validation error names the model's own arguments.
5
+ import { validateToolArguments } from "@earendil-works/pi-ai";
6
+
7
+ const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
8
+
9
+ function passes(tool, args) {
10
+ try {
11
+ validateToolArguments(tool, { name: tool.name, arguments: args });
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ // A key that is no identifier ("/invoke", "invoke name=\"…\"") is a model's own tool-call markup
19
+ // leaking into the arguments. No tool takes such a key, so it carries no meaning to refuse.
20
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/u;
21
+
22
+ function withoutEmpty(args) {
23
+ return Object.fromEntries(Object.entries(args).filter(([key, value]) => IDENTIFIER.test(key) &&
24
+ value !== null && value !== undefined && !(typeof value === "string" && !value.trim())));
25
+ }
26
+
27
+ const PLAYER_ACTIONS = new Map([
28
+ ["play", "resume"], ["start", "resume"], ["continue", "resume"], ["unpause", "resume"], ["playback", "resume"],
29
+ ["stop", "pause"], ["skip", "next"], ["forward", "next"], ["next_track", "next"],
30
+ ["back", "previous"], ["prev", "previous"], ["previous_track", "previous"],
31
+ ["set_volume", "volume"], ["set_shuffle", "shuffle"], ["set_repeat", "repeat"],
32
+ ]);
33
+ const PLAYER_SOURCES = ["item_ref_id", "uri", "context_uri", "context_ref_id", "track_refs", "songs", "pending_plan"];
34
+
35
+ const repairs = {
36
+ moondog_spotify_player_control(args) {
37
+ const result = { ...args };
38
+ if (typeof result.action === "string") {
39
+ const action = result.action.trim().toLowerCase().replace(/[\s-]+/gu, "_");
40
+ result.action = PLAYER_ACTIONS.get(action) ?? action;
41
+ }
42
+ // One song, or a song given at the top level, is the list of one the tool takes.
43
+ if (result.songs === undefined && (result.song !== undefined || (typeof result.title === "string" && typeof result.artist === "string"))) {
44
+ result.songs = result.song ?? { title: result.title, artist: result.artist };
45
+ delete result.song;
46
+ delete result.title;
47
+ delete result.artist;
48
+ }
49
+ // A song keeps every field it came with: an extra one ("version": "Live") may be a constraint
50
+ // the tool cannot honor, and validation must refuse it rather than play another recording.
51
+ if (isPlainObject(result.songs)) result.songs = [result.songs];
52
+ if (typeof result.track_refs === "string") result.track_refs = [result.track_refs];
53
+ // A search's type copied into the play call says nothing the player needs.
54
+ delete result.type;
55
+ if (result.action === "volume" && result.percent === undefined && result.volume !== undefined) {
56
+ result.percent = result.volume;
57
+ delete result.volume;
58
+ }
59
+ if (result.action === undefined && PLAYER_SOURCES.some((key) => result[key] !== undefined)) result.action = "resume";
60
+ // Only resume plays a source; another action with one leaves the source out.
61
+ if (result.action && result.action !== "resume") for (const key of PLAYER_SOURCES) delete result[key];
62
+ return result;
63
+ },
64
+ moondog_spotify_search(args) {
65
+ const types = new Map([["song", "track"], ["songs", "track"], ["tracks", "track"], ["albums", "album"], ["artists", "artist"], ["playlists", "playlist"]]);
66
+ if (typeof args.type !== "string") return args;
67
+ const type = args.type.trim().toLowerCase();
68
+ return { ...args, type: types.get(type) ?? type };
69
+ },
70
+ };
71
+
72
+ /** The arguments to validate: the model's own when they pass, or a repair that passes. */
73
+ export function repairToolArguments(tool, args) {
74
+ let parsed = args;
75
+ if (typeof parsed === "string") {
76
+ try { parsed = JSON.parse(parsed); } catch { return args; }
77
+ }
78
+ if (!isPlainObject(parsed) || passes(tool, parsed)) return parsed;
79
+ // An unknown key is never dropped in general: it can carry meaning the tool must refuse, such
80
+ // as a subject the model may not choose. Only a tool's own repair removes a key it knows is noise.
81
+ let repaired = withoutEmpty(parsed);
82
+ repaired = repairs[tool.name]?.(repaired) ?? repaired;
83
+ return passes(tool, repaired) ? repaired : args;
84
+ }
85
+
86
+ /** The tool with its repair step; Pi runs prepareArguments before it validates a call. */
87
+ export function withArgumentRepair(tool) {
88
+ return { ...tool, prepareArguments: (args) => repairToolArguments(tool, args) };
89
+ }
@@ -125,6 +125,7 @@ const slashCommands = [
125
125
  { name: "endpoint", description: N_("Add a model service by its address") },
126
126
  { name: "web", description: N_("Look things up on public music sites") },
127
127
  { name: "service", description: N_("Choose where Moondog plays music") },
128
+ { name: "library", description: N_("Your saved songs and playlists") },
128
129
  { name: "generation", description: N_("Let Moondog make music on this Mac") },
129
130
  { name: "onair", description: N_("Go on air: Moondog keeps making music from your taste") },
130
131
  { name: "spotify", description: N_("Connect and control Spotify") },
@@ -250,7 +251,8 @@ export async function runMoondogTui({
250
251
  // /onair needs both: the room's own player and music making here.
251
252
  const onAirControls = previewControls && typeof generation?.save === "function";
252
253
  const commands = slashCommands.filter(({ name }) => (previewControls || !["skip", "stop"].includes(name)) &&
253
- (name !== "generation" || typeof generation?.save === "function") && (name !== "onair" || onAirControls));
254
+ (name !== "generation" || typeof generation?.save === "function") && (name !== "onair" || onAirControls) &&
255
+ (name !== "library" || application.libraryReady?.() === true));
254
256
  const localizedCommands = commands.map(({ name, description }) => ({ name, get description() { return tr.marked(description); } }));
255
257
  let activeRuntime = runtime;
256
258
  application.startNewSession?.("tui_start");
@@ -317,6 +319,7 @@ export async function runMoondogTui({
317
319
  let busy = false;
318
320
  // A model chosen or a sign-in made in the window while a turn ran here; it applies once the turn ends.
319
321
  let modelChangedElsewhere = false;
322
+ let modelAskedFor = null;
320
323
  // A reply another Moondog window or terminal is writing in this conversation: { id, response, text }.
321
324
  let elsewhere = null;
322
325
  let watchTimer = null;
@@ -642,6 +645,7 @@ export async function runMoondogTui({
642
645
  }
643
646
  if (!cleanedUp) tui.requestRender();
644
647
  if (!value && modelChangedElsewhere) void followModel();
648
+ if (!value && modelAskedFor) void applyModelAskedFor();
645
649
  };
646
650
  const setFooter = (text, color = dim) => {
647
651
  if (cleanedUp) return;
@@ -1035,6 +1039,59 @@ export async function runMoondogTui({
1035
1039
  generationFooter(status);
1036
1040
  };
1037
1041
 
1042
+ // /library lists Saved and the listener's folders and playlists, plays one by name, or keeps the song
1043
+ // playing now, in Saved or in a playlist by name (made when there is none). The window shares this library.
1044
+ const manageLibrary = async (args) => {
1045
+ if (!application.libraryReady?.()) throw new Error(tr("The library isn't available here."));
1046
+ const [action, ...rest] = args;
1047
+ const words = rest.join(" ").trim();
1048
+ const nameOf = (playlist) => (playlist.saved ? tr("Saved") : playlist.name);
1049
+ if (!action) {
1050
+ const { folders, playlists } = application.libraryView();
1051
+ const count = (playlist) => tr.n(playlist.count, "{count} song", "{count} songs");
1052
+ const lines = [];
1053
+ const saved = playlists.find((playlist) => playlist.saved);
1054
+ if (saved) lines.push(`- **${nameOf(saved)}** · ${count(saved)}`);
1055
+ const walk = (parentId, depth) => {
1056
+ const nodes = [...folders.filter((folder) => folder.parentId === parentId).map((node) => ["folder", node]),
1057
+ ...playlists.filter((playlist) => !playlist.saved && playlist.folderId === parentId).map((node) => ["playlist", node])]
1058
+ .sort((a, b) => a[1].position - b[1].position);
1059
+ for (const [kind, node] of nodes) {
1060
+ const indent = " ".repeat(depth);
1061
+ if (kind === "folder") { lines.push(`${indent}- ${node.name}/`); walk(node.id, depth + 1); }
1062
+ else lines.push(`${indent}- **${node.name}** · ${count(node)}`);
1063
+ }
1064
+ };
1065
+ walk(null, 0);
1066
+ addMoondogMessage(`${lines.join("\n")}\n\n${tr("Play one with `/library play <name>`, or keep the song playing now with `/library save`.")}`);
1067
+ return;
1068
+ }
1069
+ if (action === "play") {
1070
+ const [playlist] = application.findLibraryPlaylists(words);
1071
+ if (!playlist || !words) throw new Error(tr("No playlist is called “{name}”.", { name: words }));
1072
+ const played = await application.playLibrary({ playlistId: playlist.id });
1073
+ if (played.state !== "playing") {
1074
+ addMoondogMessage(played.state === "empty" ? tr("{name} has nothing in it yet.", { name: nameOf(playlist) }) : tr("None of its songs could be found."));
1075
+ return;
1076
+ }
1077
+ addMoondogMessage([tr("Playing {name}.", { name: nameOf(playlist) }),
1078
+ played.left_out_made ? tr("Moondog's tracks were left out. They play with Apple Music chosen.") : "",
1079
+ played.not_found ? tr("Some songs couldn't be found.") : ""].filter(Boolean).join(" "));
1080
+ setFooter(tr("Playing {name}.", { name: nameOf(playlist) }), success);
1081
+ return;
1082
+ }
1083
+ if (action === "save") {
1084
+ const target = words ? application.findLibraryPlaylists(words).find((playlist) => playlist.name.toLocaleLowerCase() === words.toLocaleLowerCase() || (playlist.saved && /^saved$/iu.test(words))) : null;
1085
+ const saved = await application.saveToLibrary({ from: "now_playing", ...(target ? { playlistId: target.id } : words ? { playlistName: words } : {}) });
1086
+ const where = saved.playlist.saved ? tr("Saved") : saved.playlist.name;
1087
+ const text = !saved.added ? tr("Already in {name}.", { name: where }) : saved.playlist.saved ? tr("Saved.") : tr("Saved to {name}.", { name: where });
1088
+ addMoondogMessage(text);
1089
+ setFooter(text, success);
1090
+ return;
1091
+ }
1092
+ throw new Error(tr("Use /library, /library play <name>, or /library save [playlist]."));
1093
+ };
1094
+
1038
1095
  const removeLifecycleListeners = () => {
1039
1096
  signalTarget.removeListener("SIGTERM", handleSigterm);
1040
1097
  signalTarget.removeListener("SIGHUP", handleSighup);
@@ -1050,6 +1107,7 @@ export async function runMoondogTui({
1050
1107
  cleanedUp = true;
1051
1108
  clearTimeout(watchTimer);
1052
1109
  stopWatchingModel?.();
1110
+ stopModelSwitch?.();
1053
1111
  stopWatchingFirstRun?.();
1054
1112
  stopWatchingPreviews?.();
1055
1113
  stopWatchingStream?.();
@@ -1332,6 +1390,27 @@ export async function runMoondogTui({
1332
1390
  void refreshFirstRun();
1333
1391
  return nextStatus;
1334
1392
  };
1393
+ // A model the listener asked the agent for in words, switched to as /model does once the reply ends.
1394
+ // The terminal stays busy while it switches, so a message queued during the reply waits for the new model.
1395
+ const applyModelAskedFor = async () => {
1396
+ const selection = modelAskedFor;
1397
+ modelAskedFor = null;
1398
+ if (!selection || cleanedUp || busy) { modelAskedFor ??= selection; return; }
1399
+ setBusy(true);
1400
+ let status = null;
1401
+ try {
1402
+ status = await replaceRuntime(runtimeSelection(selection.provider, selection.model, selection.effort ?? runtimeStatus.effort)).catch(() => null);
1403
+ } finally {
1404
+ setBusy(false);
1405
+ }
1406
+ if (status?.state === "configured") setFooter(tr("Model ready."), success);
1407
+ settleQueue();
1408
+ };
1409
+ const stopModelSwitch = application.modelSwitch?.subscribe?.(({ application: from, selection }) => {
1410
+ if (from !== application) return;
1411
+ modelAskedFor = selection;
1412
+ if (!busy) void applyModelAskedFor();
1413
+ });
1335
1414
  // The window saved another model, or a sign-in that lets this side answer. The terminal's own
1336
1415
  // choices rebuild at once, so their saves find this agent already on them.
1337
1416
  const followModel = async () => {
@@ -2414,6 +2493,8 @@ export async function runMoondogTui({
2414
2493
  application.locale = locale;
2415
2494
  // A chosen language also becomes the conversation's, so the next short reply follows it.
2416
2495
  application.conversationLocale = locale;
2496
+ // A language chosen here ends one the listener asked the agent to keep.
2497
+ application.replyLanguagePin = null;
2417
2498
  await saveLanguage?.(locale);
2418
2499
  const pinned = normalizeLocale(environment.MOONDOG_LANGUAGE);
2419
2500
  transcript.invalidate();
@@ -2473,6 +2554,10 @@ export async function runMoondogTui({
2473
2554
  await manageGeneration(args);
2474
2555
  return;
2475
2556
  }
2557
+ if (command === "library") {
2558
+ await manageLibrary(args);
2559
+ return;
2560
+ }
2476
2561
  if (command === "spotify") {
2477
2562
  await runSpotifyCommand(args);
2478
2563
  return;