@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.
- package/README.md +30 -4
- package/package.json +1 -1
- package/scripts/moondog.mjs +148 -14
- package/src/core/apple-projection-domain-services.mjs +4 -0
- package/src/core/capability-catalog.mjs +49 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +80 -6
- package/src/core/listening-profile-domain-services.mjs +4 -0
- package/src/core/moondog-application.mjs +578 -32
- package/src/i18n/index.mjs +6 -3
- package/src/i18n/messages/de.mjs +1 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +1 -0
- package/src/i18n/messages/fr.mjs +1 -0
- package/src/i18n/messages/it.mjs +1 -0
- package/src/i18n/messages/ja.mjs +1 -0
- package/src/i18n/messages/ko.mjs +1 -0
- package/src/i18n/messages/pt.mjs +1 -0
- package/src/i18n/messages/zh-Hant.mjs +1 -0
- package/src/i18n/messages/zh.mjs +1 -0
- package/src/i18n/preferences.mjs +24 -0
- package/src/i18n/screen/es.mjs +94 -0
- package/src/i18n/screen/ja.mjs +94 -0
- package/src/i18n/screen/pt.mjs +94 -0
- package/src/i18n/screen/zh.mjs +94 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/playlist-links.mjs +102 -0
- package/src/integrations/public-playlists.mjs +39 -8
- package/src/integrations/spotify/service.mjs +22 -1
- package/src/integrations/stable-audio/cover.mjs +324 -0
- package/src/integrations/stable-audio/local-generator.mjs +259 -87
- package/src/integrations/stable-audio/setup.mjs +653 -0
- package/src/integrations/stable-audio/sources.mjs +106 -0
- package/src/integrations/web/deepseek-web.mjs +1 -1
- package/src/integrations/web/web-research.mjs +84 -17
- package/src/integrations/web/web-result.mjs +60 -12
- package/src/profile/listening-history-store.mjs +55 -1
- package/src/profile/listening-profile-projection.mjs +1 -1
- package/src/profile/music-import-bundle.mjs +3 -1
- package/src/profile/music-providers.mjs +3 -0
- package/src/profile/taste-compiler.mjs +14 -5
- package/src/runtime/pi/agent-runtime.mjs +359 -37
- package/src/runtime/pi/configured-runtime.mjs +3 -0
- package/src/runtime/pi/image-input.mjs +148 -0
- package/src/runtime/pi/model-roles.mjs +121 -0
- package/src/runtime/pi/model-settings.mjs +63 -2
- package/src/runtime/pi/model-switch.mjs +26 -0
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/record-name.mjs +80 -0
- package/src/runtime/pi/reply-language.mjs +45 -0
- package/src/runtime/pi/runtime-settings.mjs +4 -2
- package/src/runtime/pi/tool-arguments.mjs +89 -0
- package/src/surfaces/cli/command-completions.mjs +15 -1
- package/src/surfaces/cli/model-roles-command.mjs +60 -0
- 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.
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
107
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
31
|
+
summary: { type: "string", maxLength: textChars },
|
|
27
32
|
sources: {
|
|
28
|
-
type: "array", maxItems: webLimits.
|
|
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
|
|
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 (!
|
|
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.
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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(" ");
|