@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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/scripts/moondog.mjs +15 -5
- package/src/core/capability-catalog.mjs +38 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +7 -2
- package/src/core/moondog-application.mjs +379 -10
- package/src/i18n/index.mjs +6 -3
- package/src/i18n/preferences.mjs +24 -0
- package/src/i18n/screen/es.mjs +18 -0
- package/src/i18n/screen/ja.mjs +18 -0
- package/src/i18n/screen/pt.mjs +18 -0
- package/src/i18n/screen/zh.mjs +18 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +22 -2
- package/src/integrations/spotify/service.mjs +2 -1
- package/src/integrations/stable-audio/local-generator.mjs +8 -1
- package/src/runtime/pi/agent-runtime.mjs +250 -20
- package/src/runtime/pi/model-switch.mjs +26 -0
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/reply-language.mjs +45 -0
- package/src/runtime/pi/tool-arguments.mjs +89 -0
- package/src/surfaces/cli/tui.mjs +86 -1
|
@@ -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
|
+
}
|
package/src/surfaces/cli/tui.mjs
CHANGED
|
@@ -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;
|