@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.11

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 (57) hide show
  1. package/README.md +30 -4
  2. package/package.json +1 -1
  3. package/scripts/moondog.mjs +148 -14
  4. package/src/core/apple-projection-domain-services.mjs +4 -0
  5. package/src/core/capability-catalog.mjs +49 -0
  6. package/src/core/library-store.mjs +500 -0
  7. package/src/core/listening-now.mjs +80 -6
  8. package/src/core/listening-profile-domain-services.mjs +4 -0
  9. package/src/core/moondog-application.mjs +578 -32
  10. package/src/i18n/index.mjs +6 -3
  11. package/src/i18n/messages/de.mjs +1 -0
  12. package/src/i18n/messages/en.mjs +2 -0
  13. package/src/i18n/messages/es.mjs +1 -0
  14. package/src/i18n/messages/fr.mjs +1 -0
  15. package/src/i18n/messages/it.mjs +1 -0
  16. package/src/i18n/messages/ja.mjs +1 -0
  17. package/src/i18n/messages/ko.mjs +1 -0
  18. package/src/i18n/messages/pt.mjs +1 -0
  19. package/src/i18n/messages/zh-Hant.mjs +1 -0
  20. package/src/i18n/messages/zh.mjs +1 -0
  21. package/src/i18n/preferences.mjs +24 -0
  22. package/src/i18n/screen/es.mjs +94 -0
  23. package/src/i18n/screen/ja.mjs +94 -0
  24. package/src/i18n/screen/pt.mjs +94 -0
  25. package/src/i18n/screen/zh.mjs +94 -0
  26. package/src/integrations/apple-music/music-app.mjs +9 -3
  27. package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
  28. package/src/integrations/netease/ncm-cli-player.mjs +12 -6
  29. package/src/integrations/playlist-links.mjs +102 -0
  30. package/src/integrations/public-playlists.mjs +39 -8
  31. package/src/integrations/spotify/service.mjs +22 -1
  32. package/src/integrations/stable-audio/cover.mjs +324 -0
  33. package/src/integrations/stable-audio/local-generator.mjs +259 -87
  34. package/src/integrations/stable-audio/setup.mjs +653 -0
  35. package/src/integrations/stable-audio/sources.mjs +106 -0
  36. package/src/integrations/web/deepseek-web.mjs +1 -1
  37. package/src/integrations/web/web-research.mjs +84 -17
  38. package/src/integrations/web/web-result.mjs +60 -12
  39. package/src/profile/listening-history-store.mjs +55 -1
  40. package/src/profile/listening-profile-projection.mjs +1 -1
  41. package/src/profile/music-import-bundle.mjs +3 -1
  42. package/src/profile/music-providers.mjs +3 -0
  43. package/src/profile/taste-compiler.mjs +14 -5
  44. package/src/runtime/pi/agent-runtime.mjs +359 -37
  45. package/src/runtime/pi/configured-runtime.mjs +3 -0
  46. package/src/runtime/pi/image-input.mjs +148 -0
  47. package/src/runtime/pi/model-roles.mjs +121 -0
  48. package/src/runtime/pi/model-settings.mjs +63 -2
  49. package/src/runtime/pi/model-switch.mjs +26 -0
  50. package/src/runtime/pi/model-transport.mjs +34 -4
  51. package/src/runtime/pi/record-name.mjs +80 -0
  52. package/src/runtime/pi/reply-language.mjs +45 -0
  53. package/src/runtime/pi/runtime-settings.mjs +4 -2
  54. package/src/runtime/pi/tool-arguments.mjs +89 -0
  55. package/src/surfaces/cli/command-completions.mjs +15 -1
  56. package/src/surfaces/cli/model-roles-command.mjs +60 -0
  57. package/src/surfaces/cli/tui.mjs +419 -19
@@ -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
+ }
@@ -24,6 +24,14 @@ const serviceChoices = choices([
24
24
  ["apple_music", N_("30-second previews, played here")],
25
25
  ["netease", N_("Whole songs through ncm-cli")],
26
26
  ]);
27
+ const generationChoices = choices([["on", N_("Make music on this Mac with Stable Audio 3")], ["off", N_("Stop making music here")]]);
28
+ const generationSetupChoices = choices([
29
+ ["download", N_("Download a Stable Audio 3 model from Stability AI")],
30
+ ["use", N_("Make music with a downloaded model")],
31
+ ["remove", N_("Delete a downloaded model from this Mac")],
32
+ ["cancel", N_("Stop the download")],
33
+ ]);
34
+ const sa3ModelChoices = choices([["medium", N_("Medium: best sound, about 5.2 GB")], ["small", N_("Small Music: faster, lighter, about 1.7 GB")]]);
27
35
  const webChoices = choices([
28
36
  ["search", N_("Search public music sites")],
29
37
  ["read", N_("Read a public page")],
@@ -78,6 +86,7 @@ export function withCommandCompletions(commands, {
78
86
  providers = () => [],
79
87
  models = () => [],
80
88
  authProviderIds = [],
89
+ generationSetup = false,
81
90
  tr,
82
91
  } = {}) {
83
92
  if (tr) translate = tr;
@@ -93,7 +102,12 @@ export function withCommandCompletions(commands, {
93
102
  art: { argumentHint: "[braille|ascii|off|auto]", choices: firstArgument(artChoices) },
94
103
  motion: { argumentHint: "[on|off]", choices: firstArgument(motionChoices) },
95
104
  service: { argumentHint: "[spotify|apple_music|netease]", choices: firstArgument(serviceChoices) },
96
- generation: { argumentHint: "[on [folder]|off]", choices: firstArgument(choices([["on", N_("Make music on this Mac with Stable Audio 3")], ["off", N_("Stop making music here")]])) },
105
+ // With the core's setup, /generation also downloads, uses and removes Moondog's own models.
106
+ generation: generationSetup ? {
107
+ argumentHint: "[on [folder]|off|download|use|remove|cancel]",
108
+ choices: (previous) => previous.length === 0 ? [...generationChoices, ...generationSetupChoices]
109
+ : previous.length === 1 && ["download", "use", "remove"].includes(previous[0]) ? sa3ModelChoices : [],
110
+ } : { argumentHint: "[on [folder]|off]", choices: firstArgument(generationChoices) },
97
111
  lyrics: { argumentHint: "[sync]", choices: firstArgument(choices([["sync", N_("Check profile songs for lyrics")]])) },
98
112
  web: { argumentHint: "search <query> | read <url> | status", choices: firstArgument(webChoices) },
99
113
  spotify: {
@@ -0,0 +1,60 @@
1
+ import { MODEL_ROLES, WEB_SERVICES } from "../../runtime/pi/model-roles.mjs";
2
+
3
+ // /model roles, /model vision and /model web: the terminal's side of Settings' Auxiliary models.
4
+ // The chat model stays /model <provider> <model>.
5
+ export const MODEL_ROLE_WORDS = Object.freeze({ roles: null, vision: "vision", web: "web_search", audio: "audio" });
6
+
7
+ const titles = { chat: "Chat", vision: "Vision", web_search: "Web search", audio: "Audio" };
8
+ const usage = "Usage: /model roles, /model vision auto|<provider> <model>, or /model web auto|deepseek|brave|codex.";
9
+
10
+ const modelName = (choice) => `${choice.name ?? choice.model} (${choice.providerName ?? choice.provider})`;
11
+
12
+ function describe(role) {
13
+ if (role.state !== "enabled") return "not available yet; its place is kept for a later Moondog";
14
+ if (role.id === "chat") return role.choice ? modelName(role.choice) : "none chosen; /model picks one";
15
+ if (role.kind === "service") {
16
+ const service = (id) => role.services?.find((entry) => entry.id === id || entry.provider === id);
17
+ if (role.choice) {
18
+ const chosen = service(role.choice.service);
19
+ return `${chosen?.name ?? role.choice.service}${chosen && !chosen.ready ? " (not set up; falls back to the next service)" : ""}`;
20
+ }
21
+ const used = service(role.search);
22
+ return used?.ready ? `auto, now ${used.name}` : "auto, but no service is set up; /web status says how";
23
+ }
24
+ if (role.choice) return modelName(role.choice);
25
+ return role.auto ? `auto, now ${modelName(role.auto)}` : "auto, but the chat model can't see images";
26
+ }
27
+
28
+ export function formatModelRoles(roles) {
29
+ const lines = roles.map((role) => `- ${titles[role.id] ?? role.id}: ${describe(role)}`);
30
+ return `# Chat and auxiliary models\n\n${lines.join("\n")}\n\n` +
31
+ "`/model vision <provider> <model>` gives images a model of their own, and `/model vision auto` gives them back to the chat model when it can see.\n" +
32
+ "`/model web deepseek|brave|codex` picks the web search service, and `/model web auto` takes the first one set up.";
33
+ }
34
+
35
+ export async function runModelRolesCommand({ args = [], modelSettings, webResearch } = {}) {
36
+ if (!modelSettings?.roles) throw new Error("Model roles aren't available when Moondog is started this way.");
37
+ const [word, ...rest] = args;
38
+ const roleId = MODEL_ROLE_WORDS[String(word ?? "").toLowerCase()];
39
+ const show = async () => formatModelRoles(await modelSettings.roles({ web: await webResearch?.services?.().catch(() => null) ?? null }));
40
+ if (roleId === null) {
41
+ if (rest.length) throw new Error(usage);
42
+ return show();
43
+ }
44
+ if (roleId === undefined) throw new Error(usage);
45
+ const role = MODEL_ROLES.find((entry) => entry.id === roleId);
46
+ if (role.state !== "enabled") return `${titles[roleId]} isn't available yet. Its place is kept, so a later Moondog can add it.`;
47
+ if (!rest.length) return show();
48
+ let choice;
49
+ if (rest.length === 1 && rest[0].toLowerCase() === "auto") choice = null;
50
+ else if (role.kind === "service") {
51
+ if (rest.length !== 1 || !WEB_SERVICES.includes(rest[0].toLowerCase())) throw new Error(usage);
52
+ choice = { service: rest[0].toLowerCase() };
53
+ } else {
54
+ if (rest.length !== 2) throw new Error(usage);
55
+ choice = { provider: rest[0], model: rest[1] };
56
+ }
57
+ await modelSettings.setRole(roleId, choice);
58
+ if (roleId === "web_search") await webResearch?.refresh?.();
59
+ return show();
60
+ }