@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,106 @@
1
+ // The official sources Moondog's one-click setup downloads Stable Audio 3 from, each pinned to
2
+ // the release, commit or snapshot tested with Moondog on Apple silicon, and the files every
3
+ // download is verified against. Nothing here is fetched until the listener asks for a model.
4
+
5
+ // The models a listener can choose, with the flags sa3_mlx.py takes and the weights text to music
6
+ // loads. The codec encoder is only for --init-audio, so it is never downloaded.
7
+ export const STABLE_AUDIO_MODELS = Object.freeze({
8
+ medium: Object.freeze({ dit: "medium", decoder: "same-l", receipt: "sa3-medium-ARC", weights: Object.freeze(["dit_medium_f16.npz", "same_l_decoder_f32.npz"]) }),
9
+ "small-music": Object.freeze({ dit: "sm-music", decoder: "same-s", receipt: "sa3-sm-music-ARC", weights: Object.freeze(["dit_sm-music_f16.npz", "same_s_decoder_f32.npz"]) }),
10
+ });
11
+ export const DEFAULT_STABLE_AUDIO_MODEL = "medium";
12
+ // The text encoder every model needs.
13
+ export const SHARED_WEIGHTS = Object.freeze(["t5gemma_f16.npz"]);
14
+ export const isStableAudioModel = (value) => typeof value === "string" && Object.hasOwn(STABLE_AUDIO_MODELS, value);
15
+
16
+ const HF_SNAPSHOT = "08b17423649b5714b22eab20f96bf00751f2358a";
17
+ const CODE_COMMIT = "77e0398c26f7d22d801ec1bb036307e8b231f741";
18
+
19
+ export const STABLE_AUDIO_SOURCES = Object.freeze({
20
+ // uv makes the Python environment, so the listener installs nothing.
21
+ uv: Object.freeze({
22
+ version: "0.9.7",
23
+ url: "https://github.com/astral-sh/uv/releases/download/0.9.7/uv-aarch64-apple-darwin.tar.gz",
24
+ size: 18_372_336,
25
+ sha256: "35572b9619fc14d67fc1cd72582c3cfc5c9c66d97f310192e04f26fb3fe96005",
26
+ }),
27
+ // Stability AI's code (MIT). A GitHub tarball's bytes may change between downloads, so each file
28
+ // extracted is checked against this manifest instead: [path in optimized/mlx, bytes, sha256].
29
+ code: Object.freeze({
30
+ commit: CODE_COMMIT,
31
+ url: `https://codeload.github.com/Stability-AI/stable-audio-3/tar.gz/${CODE_COMMIT}`,
32
+ folder: `stable-audio-3-${CODE_COMMIT}/optimized/mlx`,
33
+ files: Object.freeze([
34
+ [".gitignore", 420, "0dbc69aa372ac835f6ec00d4d18941443993b9d357722e6775857544eb0b4fa3"],
35
+ ["README.md", 20260, "5fce0300becfed583577b0e0afacf6f68696196cd6597f136a455b17269e5c31"],
36
+ ["TRAINING_CONVENTIONS.md", 14933, "3e915aefada47d3c56dc53f0fafd8ba274df0213b3cd6fb0d986e10420c1c727"],
37
+ ["ableton/AudioInserter/AudioInserter.py", 5573, "9f318c73c8f4b81f15adb9d853334c9e6f8411e907044fb8c933e5c1fa98ef20"],
38
+ ["ableton/AudioInserter/__init__.py", 112, "8530d242ecf41046d7e88b3e3aed707e2faa039db33630769dcfcdc297aa046c"],
39
+ ["ableton/README.md", 2095, "2372e71f8e5fc05d8c4c3d609388e8a3a7390901d1af27eb897a018d8c29720c"],
40
+ ["ableton/insert_audio.py", 7718, "439ec109d31c35c4da488e43b8593c315f2a0925d712538e0e813a5e565fd68d"],
41
+ ["benchmark.sh", 1515, "1ca7b070f6272b3814214025d3d33849d3f0c12f2cf42ee830c2a3ea82db1aa5"],
42
+ ["bootstrap.sh", 8812, "d05fa7f5849de1f2f8a31462e4bba174d3fdddcbd7ecd4bbbff3c0bc12199b44"],
43
+ ["install.sh", 6509, "ca6b0af0325a9f6f43b5f2e6aec19fcba82e035b4dc55c74600fc43c38c027ec"],
44
+ ["models/__init__.py", 0, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"],
45
+ ["models/defs/__init__.py", 0, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"],
46
+ ["models/defs/audio_encoding.py", 9380, "f31aa0026d5292adfa6eab288d076747fd04200d829de2defdc751a974d0d784"],
47
+ ["models/defs/demo_mlx.py", 12751, "dfa7ec9ec4820538e95913a51409da060b7202ffee30eb2ac993acc335d305df"],
48
+ ["models/defs/dit_mlx.py", 13470, "7a57f7140c1c2ecde477c1029770f05adf1b77f8dd876a1231c5255da509e75a"],
49
+ ["models/defs/dit_mlx_medium.py", 17685, "12f4e0743f0c00f010f7e6b962e1de921378d1f4b3a3b77e6f106ece15e3ee4a"],
50
+ ["models/defs/latent_dataset.py", 14908, "8be23c873273f42167069321fd69751e33187bd779669d2c2e75a2b1a17f7177"],
51
+ ["models/defs/lora.py", 52582, "85464d21291d8ba8d9b1d281260ededc926d7fe91271945c7611cdef46fb30a0"],
52
+ ["models/defs/lora_merge.py", 40136, "5a1738c078b088b367c24d7d7c12ab3a99b09e5c703ac9a5c343b5de4c121758"],
53
+ ["models/defs/sa3_pipeline.py", 10775, "e8aca5225eeda40e1d3f53c04968bbce9df6290021f4bdc5c1a67d6d68013de8"],
54
+ ["models/defs/same_l_decoder.py", 12795, "c9c4922169423407ee6acbf6e7c88a46761d75cacecdac4de24d4a484e762622"],
55
+ ["models/defs/same_l_encoder.py", 5720, "71338711802fb5c7aaf15fea3852110c0a828cdbe94c643c553d4332944c5722"],
56
+ ["models/defs/same_s_decoder.py", 11002, "2733dc254a67c133489108616652f353aec21566e900169b15d8d871ec57c88c"],
57
+ ["models/defs/same_s_encoder.py", 7168, "d6785bb4ed549158d7dd2a3a13b85c2ec9cccf7f6ff05e9af58120ebbfd3e1df"],
58
+ ["models/defs/t5gemma_mlx.py", 13913, "ec8f9c088c72174c75aabf9e54ce410d53ff306244ccd397657ac961683a11e3"],
59
+ ["models/defs/training.py", 7674, "9bf99e2019fab90d75dd8caac8988e4ae723e3bc47fca137cab56025d705faa8"],
60
+ ["requirements-gradio.txt", 170, "f1f4a2b95c58fc80b7881e7ee9bee98f9e565f2be58ccee63d53d5e4a8ccb896"],
61
+ ["requirements.txt", 547, "c173edcf798d31b2b4c9dc919e439de3dc7c915d82a3f4255b62a4dae516b8ad"],
62
+ ["sa3", 3108, "ff5411de89df7fdb986419ec441d57f0dd9856f89821aef48f8fd21fe1f1656b"],
63
+ ["sa3-gradio", 1247, "86cf132971ee880892cf68687201a0f2a3d3e9ed8fbb4c5d6c61a7d433cc8aa7"],
64
+ ["scripts/benchmark.py", 7201, "91c76adbfba3dadfb80fd32a3bd751be95a2b01f02a7939d3d849cbc70086715"],
65
+ ["scripts/examples.py", 6672, "011f05d5a690b2f46a7b8a0b898a4d94aa130205a121d12dfbdd18c6c8506213"],
66
+ ["scripts/export_base_npz.py", 4108, "0ef788a8ef9c679d2e8b8bb18ded0a2f775c7781df84d4dd73542971f46f62b1"],
67
+ ["scripts/install.py", 6082, "7f629fccec01fd88ae159bae3495299f04be205733916851ea5b0138d851a1a8"],
68
+ ["scripts/lora_train_mlx.py", 49794, "e5d87e10e122ff4a054ac7f0d261650830e5d77b7a9fa2a07a14452db4cfae60"],
69
+ ["scripts/parity_forward_mlx.py", 4918, "a736a3a29ea0dcccf36543e8e9f63c38b1c1bef670a51c67915b7473c665e8f1"],
70
+ ["scripts/parity_forward_torch.py", 5236, "54db7fca02a5ce618417ee52ec54d3d291d866ff67762e4fc2e95371f9af201b"],
71
+ ["scripts/pre_encode_mlx.py", 15985, "f6e4a2c7bcec19b0e9fc4316ee31b2277663a7dc0e569eb76400b44457686729"],
72
+ ["scripts/sa3_gradio.py", 69093, "72c6eafa087dd309151cc4524c93a9281c3f3bdde9580a5578458bce047a59fd"],
73
+ ["scripts/sa3_mlx.py", 45778, "312c2833fac5ca270cbeebe7037b3fbeacd87b10c9b5d057587c5eaf824fb951"],
74
+ ["scripts/spec.py", 6574, "3e85ee7850a4e3861733823c1c712a123550c48c1cbd47cbb0e0baea2f31b42d"],
75
+ ["scripts/test_all_configs.py", 9969, "db210dfb7297c640feaede146a9538b1da6ea167436c04f8921c741f472d37de"],
76
+ ["scripts/test_lora_merge.py", 20704, "efd88ade4205e53bf938135853e2c9cd53a61cccc844bc6f17dfb77409c8d36f"],
77
+ ["scripts/weights.py", 7303, "9b367084a8cfa6dffa090bf39e84f850981f689e43aab78112787fdcb3d16bf0"],
78
+ ]),
79
+ }),
80
+ // The Python and the packages that run on the Mac it was tested on; Pillow is only for training.
81
+ python: Object.freeze({
82
+ version: "3.11.14",
83
+ packages: Object.freeze([
84
+ "anyio==4.15.1", "cffi==2.1.1", "click==8.5.0", "filelock==4.2.4", "fsspec==2026.9.0", "h11==0.16.0",
85
+ "hf-xet==1.7.0", "httpcore2==2.16.0", "httpx2==2.16.0", "huggingface-hub==2.3.0", "idna==3.20",
86
+ "mlx==0.32.4", "mlx-metal==0.32.4", "numpy==2.4.6", "packaging==26.3", "pycparser==3.12", "pyyaml==6.0.3",
87
+ "sentencepiece==0.2.2", "soundfile==0.14.0", "tqdm==4.70.1", "truststore==0.10.5", "typing-extensions==4.16.0",
88
+ ]),
89
+ }),
90
+ // Stability AI's weights (Stability AI Community License; T5Gemma under the Gemma Terms of Use).
91
+ weights: Object.freeze({
92
+ url: (name) => `https://huggingface.co/stabilityai/stable-audio-3-optimized/resolve/${HF_SNAPSHOT}/MLX/${name}`,
93
+ files: Object.freeze({
94
+ "t5gemma_f16.npz": Object.freeze({ size: 567_443_068, sha256: "8deb20489f36d9aec539f26c9c67321f99bc5fe300d470435ed6e76be4f16bbd" }),
95
+ "dit_medium_f16.npz": Object.freeze({ size: 2_907_300_946, sha256: "f9e5647ea3225818657d47d47ae4b34afa29c0568206ca89566c1a758944a38e" }),
96
+ "same_l_decoder_f32.npz": Object.freeze({ size: 1_704_311_976, sha256: "84924be2122d3a20fce443f40b782d9cd88e8e73707476326003ac47659a2287" }),
97
+ "dit_sm-music_f16.npz": Object.freeze({ size: 919_193_814, sha256: "8ed3f38e2597f361ee675051f1265d9aa2ae2fffce1c61acd2e9fe31e1db1cbc" }),
98
+ "same_s_decoder_f32.npz": Object.freeze({ size: 218_090_820, sha256: "909928a8e6937c1ebe6ac4b729f0462bd3773704a11ea18278e42671dc69bfe4" }),
99
+ }),
100
+ }),
101
+ // What the listener agrees to before the first download.
102
+ licenses: Object.freeze([
103
+ Object.freeze({ id: "stability-ai-community", name: "Stability AI Community License", url: "https://stability.ai/license" }),
104
+ Object.freeze({ id: "gemma-terms", name: "Gemma Terms of Use", url: "https://huggingface.co/stabilityai/stable-audio-3-optimized/blob/main/LICENSE_GEMMA.md" }),
105
+ ]),
106
+ });
@@ -69,7 +69,7 @@ export function createDeepSeekWebResearch({ apiKey, fetch: fetchImpl = globalThi
69
69
  if (status !== 200) throw serviceError(status, body);
70
70
  const title = typeof body.title === "string" && body.title.trim() ? clip(body.title.trim(), 300) : url;
71
71
  return webResult({ provider: "deepseek", kind: "read", status: "found", retrievedAt,
72
- summary: clip(text, webLimits.textChars), truncated: text.length > webLimits.textChars,
72
+ summary: clip(text, webLimits.fullTextChars), truncated: text.length > webLimits.fullTextChars,
73
73
  sources: [{ title, url, snippet: "", published_at: null }] });
74
74
  }
75
75
 
@@ -1,3 +1,4 @@
1
+ import { readModelRoles } from "../../runtime/pi/model-roles.mjs";
1
2
  import { createPersistentCredentialStore } from "../../runtime/pi/persistent-credential-store.mjs";
2
3
  import { createBraveWebResearch } from "./brave-web.mjs";
3
4
  import { createCodexWebResearch } from "./codex-web.mjs";
@@ -5,8 +6,13 @@ import { createDeepSeekWebResearch } from "./deepseek-web.mjs";
5
6
  import { failure, webProviderName } from "./web-result.mjs";
6
7
 
7
8
  // Search goes to the first backend that is set up; reading needs a backend that can open pages.
8
- // MOONDOG_WEB_SEARCH_PROVIDER and MOONDOG_WEB_READ_PROVIDER pick one instead.
9
+ // The web search role in Settings (model-roles.json) picks one instead, and MOONDOG_WEB_SEARCH_PROVIDER
10
+ // and MOONDOG_WEB_READ_PROVIDER pick over that. As in Hermes Agent, a call that fails on the chosen
11
+ // backend is tried once more on each other backend that is set up, so one service being down or out
12
+ // of credit doesn't leave the agent without the web.
9
13
  const order = Object.freeze({ search: ["deepseek", "brave", "codex"], read: ["deepseek", "codex"] });
14
+ // Failures that another backend would only repeat.
15
+ const final = new Set(["web_cancelled", "web_input_invalid", "web_url_invalid"]);
10
16
  const providerIds = Object.freeze({ deepseek: "deepseek", brave: "brave", codex: "codex_cli" });
11
17
  const keys = Object.freeze({
12
18
  deepseek: { credential: "deepseek", variable: "DEEPSEEK_API_KEY" },
@@ -33,43 +39,103 @@ export async function createWebResearch({
33
39
  credentials = createPersistentCredentialStore(environment),
34
40
  fetch = globalThis.fetch,
35
41
  createCodex = createCodexWebResearch,
42
+ readRoles = readModelRoles,
36
43
  now,
37
44
  } = {}) {
38
- let current = { backends: {}, choices: {} };
45
+ let current = { backends: {}, choices: {}, found: {} };
46
+
47
+ // A backend whose key did not change keeps its cache. A working Codex stays; one that was not ready
48
+ // is checked again, since the listener may have signed in since.
49
+ async function backendFor(name, found, previous) {
50
+ if (name === "codex") {
51
+ const kept = previous?.backends.codex;
52
+ return kept?.publicStatus().state === "configured" ? kept : createCodex({ environment, now });
53
+ }
54
+ const kept = previous?.backends[name] && previous.found[name]?.key === found[name]?.key;
55
+ return kept ? previous.backends[name] : { deepseek: createDeepSeekWebResearch, brave: createBraveWebResearch }[name]({ apiKey: found[name]?.key, fetch, now });
56
+ }
39
57
 
40
58
  async function load(previous) {
41
59
  const found = {
42
60
  deepseek: await readKey(credentials, environment, keys.deepseek),
43
61
  brave: await readKey(credentials, environment, keys.brave),
44
62
  };
63
+ const role = (await readRoles(environment).catch(() => ({}))).web_search?.service ?? null;
45
64
  const choices = {};
46
65
  for (const kind of ["search", "read"]) {
47
66
  const wanted = environment[`MOONDOG_WEB_${kind.toUpperCase()}_PROVIDER`]?.trim().toLowerCase();
48
- if (wanted) choices[kind] = order[kind].includes(wanted) ? { name: wanted, override: true } : { name: null, reason: "web_provider_unknown", wanted };
67
+ if (wanted) choices[kind] = order[kind].includes(wanted) ? { name: wanted, selectedBy: `MOONDOG_WEB_${kind.toUpperCase()}_PROVIDER` } : { name: null, reason: "web_provider_unknown", wanted };
68
+ // Brave only searches, so with Brave chosen a read still goes to the first backend that can.
69
+ else if (role && order[kind].includes(role)) choices[kind] = { name: role, selectedBy: "settings" };
49
70
  else choices[kind] = { name: order[kind].find((name) => name !== "codex" && found[name]) ?? "codex" };
50
71
  }
51
72
  const backends = {};
52
73
  for (const name of new Set(Object.values(choices).map((choice) => choice.name).filter(Boolean))) {
53
- // A backend whose key did not change keeps its cache.
54
- const create = { deepseek: createDeepSeekWebResearch, brave: createBraveWebResearch }[name];
55
- if (create) {
56
- const kept = previous?.backends[name] && previous.found[name]?.key === found[name]?.key;
57
- backends[name] = kept ? previous.backends[name] : create({ apiKey: found[name]?.key, fetch, now });
58
- }
59
- // A working Codex stays; one that was not ready is checked again, since the listener may have signed in since.
60
- if (name === "codex") {
61
- const kept = previous?.backends.codex;
62
- backends.codex = kept?.publicStatus().state === "configured" ? kept : await createCodex({ environment, now });
63
- }
74
+ backends[name] = await backendFor(name, found, previous);
64
75
  }
65
76
  return { backends, choices, found };
66
77
  }
67
78
 
79
+ // The other backends a failed call can go to: ones with a key, and Codex once it is signed in.
80
+ async function alternatives(kind, tried) {
81
+ const names = [];
82
+ for (const name of order[kind]) {
83
+ if (name === tried) continue;
84
+ if (name !== "codex" && !current.found[name]) continue;
85
+ current.backends[name] ??= await backendFor(name, current.found, null);
86
+ if (name === "codex" && current.backends.codex.publicStatus().state !== "configured") continue;
87
+ names.push(name);
88
+ }
89
+ return names;
90
+ }
91
+
92
+ async function research(kind, input, options) {
93
+ const first = backend(kind);
94
+ const name = current.choices[kind].name;
95
+ let failed;
96
+ try {
97
+ const result = await first[kind](input, options);
98
+ // A page the chosen backend could not read may open on another.
99
+ if (kind === "search" || result.status !== "unavailable") return result;
100
+ failed = result;
101
+ } catch (error) {
102
+ if (final.has(error?.code) || options?.signal?.aborted) throw error;
103
+ failed = error;
104
+ }
105
+ for (const other of await alternatives(kind, name)) {
106
+ try {
107
+ const result = await current.backends[other][kind](input, options);
108
+ if (kind === "search" || result.status !== "unavailable") return { ...result, fallback_from: providerIds[name] };
109
+ } catch (error) {
110
+ if (final.has(error?.code) || options?.signal?.aborted) throw error;
111
+ }
112
+ }
113
+ if (failed instanceof Error) throw failed;
114
+ return failed;
115
+ }
116
+
117
+ /** Each service, whether it is set up for each operation, and which one the role chose; for Settings. */
118
+ async function services() {
119
+ const role = (await readRoles(environment).catch(() => ({}))).web_search?.service ?? null;
120
+ current.backends.codex ??= await backendFor("codex", current.found, null);
121
+ const codex = current.backends.codex.publicStatus().state === "configured";
122
+ return {
123
+ chosen: role,
124
+ search: operationStatus("search"),
125
+ read: operationStatus("read"),
126
+ services: order.search.map((name) => {
127
+ const ready = name === "codex" ? codex : Boolean(current.found[name]);
128
+ return { id: name, provider: providerIds[name], name: webProviderName(providerIds[name]), ready,
129
+ search: ready, read: ready && order.read.includes(name) };
130
+ }),
131
+ };
132
+ }
133
+
68
134
  function operationStatus(kind) {
69
135
  const choice = current.choices[kind];
70
136
  if (!choice?.name) return { provider: null, state: "unavailable", reason: choice?.reason ?? "web_provider_unknown", ...(choice?.wanted ? { requested: choice.wanted } : {}) };
71
137
  const provider = providerIds[choice.name];
72
- const override = choice.override ? { selected_by: `MOONDOG_WEB_${kind.toUpperCase()}_PROVIDER` } : {};
138
+ const override = choice.selectedBy ? { selected_by: choice.selectedBy } : {};
73
139
  if (choice.name === "codex") {
74
140
  const codex = current.backends.codex.publicStatus();
75
141
  return { provider, state: codex.state, ...(codex.reason ? { reason: codex.reason } : {}), ...override };
@@ -103,8 +169,9 @@ export async function createWebResearch({
103
169
  current = await load();
104
170
  return {
105
171
  publicStatus,
106
- search: async (input, options) => backend("search").search(input, options),
107
- read: async (input, options) => backend("read").read(input, options),
172
+ services,
173
+ search: (input, options) => research("search", input, options),
174
+ read: (input, options) => research("read", input, options),
108
175
  /** Reads keys and settings again, after /auth or /reload. */
109
176
  async refresh() {
110
177
  const previous = current;
@@ -4,8 +4,12 @@ import Ajv from "ajv";
4
4
 
5
5
  // Every web backend returns this one result shape, checked by the same rules,
6
6
  // so the agent and the screen never need to know which service answered.
7
+ // After Hermes Agent's web tools: a search gives five sources unless the agent asks for up to ten,
8
+ // and a read keeps the page's whole text, handing it out a window at a time; the agent asks for the
9
+ // next window with next_offset instead of losing everything past the first.
7
10
  export const webLimits = Object.freeze({
8
- sources: 5, snippetChars: 800, textChars: 12_000, cacheMs: 300_000, cacheEntries: 32, responseBytes: 2_000_000,
11
+ sources: 5, maxSources: 10, snippetChars: 1200, textChars: 15_000, fullTextChars: 200_000,
12
+ cacheMs: 300_000, cacheEntries: 32, responseBytes: 2_000_000,
9
13
  });
10
14
 
11
15
  // What each backend's text is, so the agent can tell a search engine's snippet from a model's summary.
@@ -19,13 +23,14 @@ export function webProviderName(provider) {
19
23
  return backends[provider]?.name ?? null;
20
24
  }
21
25
 
22
- const resultSchema = {
26
+ // A backend's result holds a page's whole text; the agent's holds one window of it.
27
+ const resultSchema = (textChars) => ({
23
28
  type: "object", additionalProperties: false,
24
29
  properties: {
25
30
  status: { type: "string", enum: ["found", "not_found", "unavailable"] },
26
- summary: { type: "string", maxLength: webLimits.textChars },
31
+ summary: { type: "string", maxLength: textChars },
27
32
  sources: {
28
- type: "array", maxItems: webLimits.sources,
33
+ type: "array", maxItems: webLimits.maxSources,
29
34
  items: {
30
35
  type: "object", additionalProperties: false,
31
36
  properties: {
@@ -39,8 +44,11 @@ const resultSchema = {
39
44
  },
40
45
  },
41
46
  required: ["status", "summary", "sources"],
42
- };
43
- const validate = new Ajv({ strict: false }).compile(resultSchema);
47
+ });
48
+ const ajv = new Ajv({ strict: false });
49
+ const validate = ajv.compile(resultSchema(webLimits.fullTextChars));
50
+ const validateWindow = ajv.compile(resultSchema(webLimits.textChars));
51
+ const offsetOf = (value) => (Number.isInteger(value) && value >= 0 ? value : null);
44
52
 
45
53
  export function failure(code, message) {
46
54
  return Object.assign(new Error(message), { code });
@@ -101,7 +109,7 @@ export function webResult({ provider, kind, status, summary = "", sources = [],
101
109
  export function projectWebResearchResult(value) {
102
110
  const payload = { status: value?.status, summary: value?.summary, sources: value?.sources };
103
111
  const backend = backends[value?.provider];
104
- if (!validate(payload) || (payload.status === "found" && !payload.sources.length) ||
112
+ if (!validateWindow(payload) || (payload.status === "found" && !payload.sources.length) ||
105
113
  !backend?.content[value.kind] || value.evidence !== backend.evidence || !Number.isFinite(Date.parse(value.retrieved_at))) {
106
114
  throw failure("web_output_invalid", `${backend?.name ?? "The web service"} returned an invalid or unsourced research result.`);
107
115
  }
@@ -110,6 +118,9 @@ export function projectWebResearchResult(value) {
110
118
  retrieved_at: new Date(value.retrieved_at).toISOString(), from_cache: value.from_cache === true,
111
119
  content_kind: backend.content[value.kind], trust: "untrusted_web_content", evidence: backend.evidence,
112
120
  ...(value.truncated === true ? { truncated: true } : {}),
121
+ ...(value.kind === "read" && offsetOf(value.total_chars) !== null ? {
122
+ text_offset: offsetOf(value.text_offset) ?? 0, total_chars: value.total_chars, next_offset: offsetOf(value.next_offset),
123
+ } : {}),
113
124
  summary: cleanText(payload.summary), sources: projectSources(payload.sources),
114
125
  };
115
126
  }
@@ -131,14 +142,46 @@ export function searchSources(items) {
131
142
  if (snippet) seen.add(snippet);
132
143
  const date = typeof item.published_at === "string" ? cleanText(item.published_at).slice(0, 100) : "";
133
144
  sources.push({ title, url, snippet, published_at: date || null });
134
- if (sources.length === webLimits.sources) break;
145
+ if (sources.length === webLimits.maxSources) break;
135
146
  }
136
147
  return sources;
137
148
  }
138
149
 
150
+ // One window of a page's text, ending at a line or word break near the limit when there is one.
151
+ function textWindow(text, offset) {
152
+ let end = Math.min(text.length, offset + webLimits.textChars);
153
+ if (end < text.length) {
154
+ const floor = offset + Math.floor(webLimits.textChars * 0.8);
155
+ const lineBreak = text.lastIndexOf("\n", end);
156
+ const space = text.lastIndexOf(" ", end);
157
+ end = lineBreak > floor ? lineBreak : space > floor ? space : end;
158
+ }
159
+ return { summary: text.slice(offset, end), end };
160
+ }
161
+
162
+ /** What one call asked for: up to `limit` sources of a search, or the window of a read from `offset`. */
163
+ function windowed(result, kind, { limit, offset }) {
164
+ if (kind === "search") return { ...result, sources: result.sources.slice(0, limit) };
165
+ const text = result.summary;
166
+ if (offset > 0 && offset >= text.length) {
167
+ throw failure("web_input_invalid", `The page has ${text.length} characters, so offset ${offset} is past its end.`);
168
+ }
169
+ const { summary, end } = textWindow(text, offset);
170
+ const more = end < text.length;
171
+ return { ...result, summary, text_offset: offset, total_chars: text.length, next_offset: more ? end : null,
172
+ ...(more || result.truncated ? { truncated: true } : {}) };
173
+ }
174
+
175
+ function integerIn(value, minimum, maximum, fallback, message) {
176
+ if (value === undefined) return fallback;
177
+ if (!Number.isInteger(value) || value < minimum || value > maximum) throw failure("web_input_invalid", message);
178
+ return value;
179
+ }
180
+
139
181
  /**
140
182
  * The parts every backend shares: one query or URL in, a cancellable request,
141
- * and a five-minute process-local cache of found results.
183
+ * and a five-minute process-local cache of found results. The cache keeps the whole result, so a
184
+ * smaller limit or a later window of the same page needs no second request.
142
185
  */
143
186
  export function createWebOperations({ unavailable = () => null, queryChars = 500, run, now = () => new Date() }) {
144
187
  const cache = new Map();
@@ -146,9 +189,14 @@ export function createWebOperations({ unavailable = () => null, queryChars = 500
146
189
  async function research(kind, input, { signal } = {}) {
147
190
  const blocked = unavailable(kind);
148
191
  if (blocked) throw blocked;
149
- if (!input || typeof input !== "object" || Object.keys(input).some((key) => key !== (kind === "search" ? "query" : "url"))) {
192
+ const allowed = kind === "search" ? ["query", "limit"] : ["url", "offset"];
193
+ if (!input || typeof input !== "object" || Object.keys(input).some((key) => !allowed.includes(key))) {
150
194
  throw failure("web_input_invalid", "Supply one search query or public URL.");
151
195
  }
196
+ const view = {
197
+ limit: integerIn(input.limit, 1, webLimits.maxSources, webLimits.sources, `Ask for 1 to ${webLimits.maxSources} search results.`),
198
+ offset: integerIn(input.offset, 0, webLimits.fullTextChars, 0, "The offset must be a whole number of characters, from 0."),
199
+ };
152
200
  const value = kind === "read" ? publicWebUrl(input.url) : typeof input.query === "string" ? input.query.trim() : input.query;
153
201
  if (typeof value !== "string" || !value) throw failure("web_input_invalid", "The web query or URL is empty.");
154
202
  if (value.length > (kind === "search" ? queryChars : 2048)) {
@@ -158,7 +206,7 @@ export function createWebOperations({ unavailable = () => null, queryChars = 500
158
206
  const key = `${kind}:${value}`;
159
207
  const cached = cache.get(key);
160
208
  if (cached && now().getTime() - Date.parse(cached.retrieved_at) < webLimits.cacheMs) {
161
- return { ...structuredClone(cached), from_cache: true };
209
+ return windowed({ ...structuredClone(cached), from_cache: true }, kind, view);
162
210
  }
163
211
  const controller = new AbortController();
164
212
  active.add(controller);
@@ -168,7 +216,7 @@ export function createWebOperations({ unavailable = () => null, queryChars = 500
168
216
  cache.set(key, result);
169
217
  if (cache.size > webLimits.cacheEntries) cache.delete(cache.keys().next().value);
170
218
  }
171
- return structuredClone(result);
219
+ return windowed(structuredClone(result), kind, view);
172
220
  } finally {
173
221
  active.delete(controller);
174
222
  }
@@ -386,7 +386,7 @@ function normalizedProfileInput(value, batch) {
386
386
  record.provenance.source_kind !== "import" ||
387
387
  !(record.provenance.source_system === "spotify_account_data" ||
388
388
  (record.provenance.source_system === "spotify_web_api" && profileImport.source_key === "spotify.web_api_library") ||
389
- (["youtube_music", "qq_music", "netease", "lastfm"].includes(record.provenance.source_system) &&
389
+ (["youtube_music", "qq_music", "netease", "lastfm", "spotify_playlist", "apple_music_playlist"].includes(record.provenance.source_system) &&
390
390
  profileImport.source_key === `${record.provenance.source_system}.music_import`)) ||
391
391
  !profileImport.member_names.includes(record.provenance.source_member) ||
392
392
  record.provenance.profile_import_id?.toLowerCase() !== profileImportId ||
@@ -844,6 +844,7 @@ export class ListeningHistoryStore {
844
844
  #coverageSources = null;
845
845
  #evidence = null;
846
846
  #ledgerChanges = 0;
847
+ #listeningDays = null;
847
848
 
848
849
  constructor(database) {
849
850
  this.#database = database;
@@ -2058,6 +2059,59 @@ export class ListeningHistoryStore {
2058
2059
  ...(started.length ? { moondog_started_plays: started.length } : {}), top_artists: artists });
2059
2060
  }
2060
2061
 
2062
+ // The listener's own plays a local day at a time, for the window's taste record: every day's plays, the
2063
+ // same for the most played artists, and the plays Moondog started, kept apart as recentAttention keeps
2064
+ // them. Counts and artist names only. Kept until the stored plays change, since a long history is many rows.
2065
+ listeningDays({ subjectId, artists = 8 } = {}) {
2066
+ if (this.#closed) throw new Error("Listening history store is closed");
2067
+ if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
2068
+ if (!Number.isSafeInteger(artists) || artists < 0 || artists > 20) throw new TypeError("Artist count is invalid");
2069
+ const subject = subjectId.toLowerCase();
2070
+ const version = `${this.listeningDataVersion()}:${subject}:${artists}`;
2071
+ if (this.#listeningDays?.version === version) return structuredClone(this.#listeningDays.value);
2072
+ const effective = `e.subject_id = ?
2073
+ AND NOT EXISTS (SELECT 1 FROM listening_event_supersessions s WHERE s.superseded_event_id = e.listening_event_id)
2074
+ AND NOT EXISTS (SELECT 1 FROM listening_event_mirrors m WHERE m.mirror_event_id = e.listening_event_id)`;
2075
+ const suggestions = this.#suggestionsForMatching();
2076
+ const since = suggestions.length ? new Date(Date.parse(suggestions[0].requested_at) - SUGGESTION_WINDOW.beforeMs).toISOString() : null;
2077
+ const started = !since ? [] : markMoondogStartedPlays(this.#database.prepare(`SELECT
2078
+ e.listening_event_id, e.record_json AS event_json, t.record_json AS track_json FROM listening_events e
2079
+ JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
2080
+ WHERE ${effective} AND e.occurred_at >= ?`)
2081
+ .all(subject, since).map((row) => ({ id: row.listening_event_id, event: JSON.parse(row.event_json), track: JSON.parse(row.track_json) })),
2082
+ suggestions).rows.filter((row) => row.moondog_suggestion_id).map((row) => row.id);
2083
+ const own = `${effective} AND e.listening_event_id NOT IN (SELECT value FROM json_each(?))`;
2084
+ const left = JSON.stringify(started);
2085
+ const pairs = (rows) => rows.map((row) => [row.day, row.plays]);
2086
+ const days = this.#database.prepare(`SELECT date(e.occurred_at, 'localtime') AS day, COUNT(*) AS plays
2087
+ FROM listening_events e WHERE ${own} GROUP BY day ORDER BY day`).all(subject, left);
2088
+ let value = null;
2089
+ if (days.length) {
2090
+ const credited = `FROM listening_events e
2091
+ JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
2092
+ JOIN json_each(t.record_json, '$.artist_credits') a
2093
+ WHERE ${own} AND json_extract(a.value, '$.name') IS NOT NULL`;
2094
+ const top = artists === 0 ? [] : this.#database.prepare(`SELECT json_extract(a.value, '$.name') AS name, COUNT(*) AS plays
2095
+ ${credited} GROUP BY name ORDER BY plays DESC, name LIMIT ?`).all(subject, left, artists);
2096
+ const byArtist = new Map(top.map((row) => [row.name, []]));
2097
+ if (top.length) {
2098
+ for (const row of this.#database.prepare(`SELECT json_extract(a.value, '$.name') AS name,
2099
+ date(e.occurred_at, 'localtime') AS day, COUNT(*) AS plays
2100
+ ${credited} AND json_extract(a.value, '$.name') IN (SELECT value FROM json_each(?))
2101
+ GROUP BY name, day ORDER BY day`).iterate(subject, left, JSON.stringify(top.map((artist) => artist.name)))) {
2102
+ byArtist.get(row.name)?.push([row.day, row.plays]);
2103
+ }
2104
+ }
2105
+ const moondog = started.length ? this.#database.prepare(`SELECT date(occurred_at, 'localtime') AS day, COUNT(*) AS plays
2106
+ FROM listening_events WHERE listening_event_id IN (SELECT value FROM json_each(?)) GROUP BY day ORDER BY day`).all(left) : [];
2107
+ value = { first_day: days[0].day, last_day: days.at(-1).day, days: pairs(days),
2108
+ artists: top.map((artist) => ({ name: artist.name, plays: artist.plays, days: byArtist.get(artist.name) })),
2109
+ moondog_days: pairs(moondog) };
2110
+ }
2111
+ this.#listeningDays = { version, value };
2112
+ return structuredClone(value);
2113
+ }
2114
+
2061
2115
  // Which spans of time the stored plays cover, and where plays may be missing.
2062
2116
  listeningCoverage({ subjectId } = {}) {
2063
2117
  if (this.#closed) throw new Error("Listening history store is closed");
@@ -1616,7 +1616,7 @@ function persistedExplanation(record) {
1616
1616
  library_artist_followed: `The artist appears in the followed-artists snapshot captured at ${record.provenance.captured_at}.`,
1617
1617
  library_track_banned: `The track appears in the explicitly banned-tracks snapshot captured at ${record.provenance.captured_at}.`,
1618
1618
  library_artist_banned: `The artist appears in the explicitly banned-artists snapshot captured at ${record.provenance.captured_at}.`,
1619
- playlist_track_added: `The track appears in the ${provider} playlist named ${JSON.stringify(attributes.playlist_name)} at position ${attributes.playlist_position}.` + (attributes.selected_public_playlist ? " You selected this public playlist; ownership and listening are not established." : ""),
1619
+ playlist_track_added: `The track appears in the ${provider} playlist named ${JSON.stringify(attributes.playlist_name)} at position ${attributes.playlist_position}.` + (attributes.listener_made ? " You made this playlist; listening is not established." : attributes.selected_public_playlist ? " You selected this public playlist; ownership and listening are not established." : ""),
1620
1620
  search_result_interacted: `A Spotify search for ${JSON.stringify(label)} led to an interaction with a ${attributes.result_entity_type} result at ${record.observed_at}.`,
1621
1621
  wrapped_track_ranked: `Spotify Wrapped ranked this track at position ${attributes.rank} for ${attributes.period}.`,
1622
1622
  wrapped_artist_ranked: `Spotify Wrapped ranked this artist at position ${attributes.rank} for ${attributes.period}.`,
@@ -58,7 +58,9 @@ export function prepareMusicBundle({ provider, subjectId, capturedAt, rows, dige
58
58
  label: title, ...(artist ? { artist_credit: artist } : {}), ...(album ? { release: album } : {}) },
59
59
  observed_at: capturedAt,
60
60
  attributes: row.playlistId ? { playlist_name: musicText(row.playlistName) || "Selected playlist",
61
- playlist_position: row.position ?? 1, playlist_id: String(row.playlistId), selected_public_playlist: true } : {},
61
+ playlist_position: row.position ?? 1, playlist_id: String(row.playlistId),
62
+ // A playlist the service says the listener made is theirs; any other one is only one they chose.
63
+ ...(row.listenerMade === true ? { listener_made: true } : { selected_public_playlist: true }) } : {},
62
64
  provenance: { source_kind: "import", source_system: provider, source_member: row.member,
63
65
  profile_import_id: profileId, captured_at: capturedAt },
64
66
  });
@@ -5,6 +5,9 @@ export const MUSIC_PROVIDER_LABELS = Object.freeze({
5
5
  lastfm: "Last.fm",
6
6
  qq_music: "QQ Music",
7
7
  netease: "NetEase Music",
8
+ // Playlists the listener imported into the library by their links.
9
+ spotify_playlist: "Spotify",
10
+ apple_music_playlist: "Apple Music",
8
11
  });
9
12
 
10
13
  export function musicProviderLabel(system) {
@@ -136,14 +136,23 @@ export function compileTaste(requested, context) {
136
136
  const usable = kept.some((descriptor) => ["genre", "instrument", "mood", "texture"].includes(descriptor.facet));
137
137
  const bpm = Number.isInteger(requested?.bpm) && requested.bpm >= BPM_RANGE.min && requested.bpm <= BPM_RANGE.max ? requested.bpm : null;
138
138
  const bpmSource = bpm && ["request", "moment", "reading"].includes(requested?.bpm_source) ? requested.bpm_source : bpm ? "moondog" : null;
139
- return { usable, descriptors: kept, dropped, bpm, ...(bpm ? { bpm_source: bpmSource } : {}), title: trackTitle(requested?.title, kept, names) };
139
+ return { usable, descriptors: kept, dropped, bpm, ...(bpm ? { bpm_source: bpmSource } : {}), title: trackTitle(requested?.title, kept, names), names };
140
140
  }
141
141
 
142
- // Moondog names its own records. A proposed title stays when it is short, plain and names no one;
143
- // otherwise the title is the sound in a few words.
144
- function trackTitle(proposed, descriptors, names) {
142
+ /**
143
+ * A name Moondog may give its own record: short, plain, naming no one (names is what compileTaste
144
+ * returned) and asking for no singing. Returns the tidied name, or null.
145
+ */
146
+ export function acceptableRecordTitle(proposed, names = {}) {
145
147
  const title = clean(proposed);
146
- if (title && Array.from(title).length <= 60 && TITLE_SHAPE.test(title) && !namedBy(title, names) && !VOCAL_WORDS.test(title)) return title;
148
+ return title && Array.from(title).length <= 60 && TITLE_SHAPE.test(title) && !namedBy(title, names) && !VOCAL_WORDS.test(title) ? title : null;
149
+ }
150
+
151
+ // Moondog names its own records. A proposed title stays when it is acceptable; otherwise the title
152
+ // is the sound in a few words.
153
+ function trackTitle(proposed, descriptors, names) {
154
+ const title = acceptableRecordTitle(proposed, names);
155
+ if (title) return title;
147
156
  const pick = (facets) => descriptors.find((descriptor) => facets.includes(descriptor.facet))?.term;
148
157
  const words = [pick(["mood"]) ?? pick(["texture"]), pick(["genre"]) ?? pick(["instrument"])].filter(Boolean);
149
158
  const text = [...new Set(words)].join(" ");