@audiofool/moondog 0.1.0

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 (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { stripVTControlCharacters } from "node:util";
5
+ import { resolveListeningHistoryPath } from "../profile/listening-history-store.mjs";
6
+ import { lyricTrackKey } from "./lyric-profile.mjs";
7
+
8
+ export function resolveLyricLibraryPath(environment = process.env) {
9
+ return path.join(path.dirname(resolveListeningHistoryPath(environment)), "lyrics.sqlite");
10
+ }
11
+
12
+ function cleanLine(text) {
13
+ return stripVTControlCharacters(text).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, "")
14
+ .replace(/\s+/gu, " ").trim();
15
+ }
16
+
17
+ /** Preserve provider text separately; this projection supplies reusable timed lines. */
18
+ export function parseLyricLines({ plainLyrics, syncedLyrics }) {
19
+ const timed = [];
20
+ const offset = Number(syncedLyrics?.match(/\[offset:([+-]?\d+)\]/iu)?.[1] ?? 0);
21
+ for (const line of (syncedLyrics ?? "").split(/\r?\n/u)) {
22
+ const stamps = [...line.matchAll(/\[(\d{1,3}):(\d{2})(?:[.:](\d{1,3}))?\]/gu)];
23
+ const text = cleanLine(line.replace(/[\[<]\d{1,3}:\d{2}(?:[.:]\d{1,3})?[\]>]/gu, ""));
24
+ for (const [, minutes, seconds, fraction = ""] of stamps) {
25
+ if (Number(seconds) >= 60) continue;
26
+ // Positive LRC offsets advance the lyric; empty timed lines mark instrumental gaps.
27
+ timed.push({ text, startMs: Math.max(0, Number(minutes) * 60_000 + Number(seconds) * 1_000 + Number(fraction.padEnd(3, "0")) - offset) });
28
+ }
29
+ }
30
+ if (timed.length) return timed.sort((a, b) => a.startMs - b.startMs);
31
+ return (plainLyrics ?? "").split(/\r?\n/u).map(cleanLine)
32
+ .filter((text) => text && !/^\[[^\]]+\]$/u.test(text)).map((text) => ({ text, startMs: null }));
33
+ }
34
+
35
+ export function homeLyricLines(lines) {
36
+ return [...new Set(lines.map((line) => line.text))].filter((text) => {
37
+ const length = Array.from(text).length;
38
+ return length >= 6 && length <= 80 && /\p{L}/u.test(text) &&
39
+ !/(?:https?:|www\.|©|作词|作曲|编曲|制作人|词[::]|曲[::]|lyrics?\s*(?:by|:)|written\s+by|copyright)/iu.test(text) &&
40
+ !/^[\[(].*[\])]$/u.test(text) && !/^(?:verse|chorus|bridge|intro|outro|instrumental)(?:\s+\d+)?$/iu.test(text);
41
+ });
42
+ }
43
+
44
+ const hash = (text) => createHash("sha256").update(text).digest("hex");
45
+
46
+ export async function openLyricLibrary({ environment = process.env, databasePath = resolveLyricLibraryPath(environment) } = {}) {
47
+ const { DatabaseSync } = await import("node:sqlite");
48
+ if (databasePath !== ":memory:") {
49
+ await mkdir(path.dirname(databasePath), { recursive: true, mode: 0o700 });
50
+ if (process.platform !== "win32") await chmod(path.dirname(databasePath), 0o700);
51
+ }
52
+ const db = new DatabaseSync(databasePath);
53
+ try {
54
+ db.exec("PRAGMA busy_timeout = 3000; PRAGMA trusted_schema = OFF;");
55
+ const version = db.prepare("PRAGMA user_version").get().user_version;
56
+ if (version > 1) throw new Error("Lyric library schema is newer than this Moondog version.");
57
+ db.exec(`
58
+ CREATE TABLE IF NOT EXISTS lyrics (
59
+ track_key TEXT PRIMARY KEY, track_json TEXT NOT NULL,
60
+ state TEXT NOT NULL, record_json TEXT NOT NULL, fetched_at INTEGER NOT NULL
61
+ ) STRICT;
62
+ CREATE TABLE IF NOT EXISTS home_selections (
63
+ id INTEGER PRIMARY KEY, subject_id TEXT NOT NULL, track_key TEXT NOT NULL,
64
+ line_hash TEXT NOT NULL, shown_at INTEGER NOT NULL
65
+ ) STRICT;
66
+ CREATE INDEX IF NOT EXISTS home_subject ON home_selections(subject_id, id DESC);
67
+ CREATE TABLE IF NOT EXISTS provider_state (provider TEXT PRIMARY KEY, retry_at INTEGER NOT NULL) STRICT;
68
+ PRAGMA user_version = 1;
69
+ `);
70
+ if (databasePath !== ":memory:" && process.platform !== "win32") await chmod(databasePath, 0o600);
71
+ return {
72
+ path: databasePath,
73
+ get(track) {
74
+ const row = db.prepare("SELECT * FROM lyrics WHERE track_key = ?").get(lyricTrackKey(track));
75
+ return row ? { ...JSON.parse(row.record_json), key: row.track_key, fetchedAt: row.fetched_at } : null;
76
+ },
77
+ put(track, record, now = Date.now()) {
78
+ const value = { ...record, lines: parseLyricLines(record) };
79
+ db.prepare(`INSERT INTO lyrics VALUES (?, ?, ?, ?, ?)
80
+ ON CONFLICT(track_key) DO UPDATE SET track_json=excluded.track_json, state=excluded.state,
81
+ record_json=excluded.record_json, fetched_at=excluded.fetched_at`)
82
+ .run(lyricTrackKey(track), JSON.stringify(track), record.state, JSON.stringify(value), now);
83
+ return value;
84
+ },
85
+ retryAt() { return db.prepare("SELECT retry_at FROM provider_state WHERE provider = 'lrclib'").get()?.retry_at ?? 0; },
86
+ deferUntil(time) {
87
+ db.prepare("INSERT INTO provider_state VALUES ('lrclib', ?) ON CONFLICT(provider) DO UPDATE SET retry_at=excluded.retry_at").run(time);
88
+ },
89
+ selectHome({ tracks, subjectId, random = Math.random, now = Date.now() }) {
90
+ if (!subjectId) return null;
91
+ const candidates = tracks.flatMap((track) => {
92
+ const record = this.get(track);
93
+ const lines = record?.state === "ready" ? homeLyricLines(record.lines) : [];
94
+ return lines.length ? [{ track, record, lines }] : [];
95
+ });
96
+ if (!candidates.length) return null;
97
+ const recent = db.prepare("SELECT track_key, line_hash FROM home_selections WHERE subject_id = ? ORDER BY id DESC LIMIT 12").all(subjectId);
98
+ const different = candidates.map((item) => ({ ...item, lines: item.lines.filter((line) => hash(line) !== recent[0]?.line_hash) })).filter((item) => item.lines.length);
99
+ const eligible = different.length ? different : candidates;
100
+ const fresh = eligible.map((item) => ({ ...item, lines: item.lines.filter((line) => !recent.some((seen) => seen.line_hash === hash(line))) })).filter((item) => item.lines.length);
101
+ const available = fresh.length ? fresh : eligible;
102
+ const otherTracks = available.filter((item) => item.record.key !== recent[0]?.track_key);
103
+ const pool = otherTracks.length ? otherTracks : available;
104
+ const total = pool.reduce((sum, item) => sum + (item.track.weight ?? 1), 0);
105
+ let cursor = random() * total;
106
+ const chosen = pool.find((item) => (cursor -= item.track.weight ?? 1) < 0) ?? pool.at(-1);
107
+ const text = chosen.lines[Math.floor(random() * chosen.lines.length)];
108
+ db.prepare("INSERT INTO home_selections(subject_id, track_key, line_hash, shown_at) VALUES (?, ?, ?, ?)")
109
+ .run(subjectId, chosen.record.key, hash(text), now);
110
+ db.prepare("DELETE FROM home_selections WHERE subject_id = ? AND id NOT IN (SELECT id FROM home_selections WHERE subject_id = ? ORDER BY id DESC LIMIT 100)").run(subjectId, subjectId);
111
+ return { text, trackKey: chosen.record.key, title: chosen.track.title, artist: chosen.track.artist, sourceUrl: chosen.record.sourceUrl };
112
+ },
113
+ status() {
114
+ return { path: databasePath, entries: db.prepare("SELECT COUNT(*) AS n FROM lyrics").get().n,
115
+ ready: db.prepare("SELECT COUNT(*) AS n FROM lyrics WHERE state = 'ready'").get().n,
116
+ instrumental: db.prepare("SELECT COUNT(*) AS n FROM lyrics WHERE state = 'instrumental'").get().n };
117
+ },
118
+ close() { db.close(); },
119
+ };
120
+ } catch (error) { db.close(); throw error; }
121
+ }
@@ -0,0 +1,50 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function lyricIdentity(value) {
4
+ return String(value ?? "").normalize("NFKC").toLocaleLowerCase("und").replace(/\s+/gu, " ").trim();
5
+ }
6
+
7
+ export function lyricTrackKey(track) {
8
+ return createHash("sha256").update(JSON.stringify([
9
+ lyricIdentity(track.title), lyricIdentity(track.artist),
10
+ lyricIdentity(track.album), track.durationSeconds ?? null,
11
+ ])).digest("hex");
12
+ }
13
+
14
+ /** Only structured preferences and listening evidence can seed the lyric library. */
15
+ export function lyricSeedsFromProfile(profile = {}) {
16
+ const assertions = profile.listener_assertions ?? {};
17
+ const curated = profile.curated_preferences ?? {};
18
+ const behavior = profile.listening_behavior ?? {};
19
+ const avoids = [
20
+ ...(profile.lyric_exclusions ?? []), ...(assertions.avoids ?? []),
21
+ ...(assertions.active ?? []).filter((item) => item.stance === "avoid"),
22
+ ...(curated.avoids ?? []),
23
+ ];
24
+ const sources = [
25
+ [(assertions.preferences ?? []).filter((item) => item.entity_type === "track"), "explicit_like", 3],
26
+ [(profile.strong_preferences ?? []).filter((item) => item.entity_type === "track" || item.track_ref_id), "preference", 2],
27
+ [curated.saved_tracks ?? [], "saved", 2],
28
+ [curated.playlist_anchors ?? [], "playlist", 1],
29
+ [(behavior.repeat_tracks ?? []).filter((item) => item.play_count >= 3), "repeated_listening", 1],
30
+ [(profile.familiarity ?? []).filter((item) => item.play_count >= 3), "repeated_listening", 1],
31
+ ];
32
+ const tracks = new Map();
33
+ for (const [items, basis, weight] of sources) {
34
+ for (const item of items) {
35
+ const title = item.label?.trim();
36
+ const artist = item.artist_credit?.trim();
37
+ if (!title || !artist || title.length > 512 || artist.length > 512 ||
38
+ /^(?:unknown|unknown artist)$/iu.test(artist)) continue;
39
+ if (avoids.some((avoid) => avoid.entity_type === "artist"
40
+ ? lyricIdentity(avoid.label) === lyricIdentity(artist)
41
+ : avoid.entity_type === "track" && lyricIdentity(avoid.label) === lyricIdentity(title) &&
42
+ lyricIdentity(avoid.artist_credit) === lyricIdentity(artist))) continue;
43
+ const track = { title, artist, basis, weight };
44
+ // Listening duration is not track duration; never use play time to match a version.
45
+ const key = lyricTrackKey(track);
46
+ if (!tracks.has(key)) tracks.set(key, track);
47
+ }
48
+ }
49
+ return [...tracks.values()].slice(0, 50);
50
+ }
@@ -0,0 +1,41 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { createLrclibProvider } from "../integrations/lyrics/lrclib.mjs";
3
+ import { openLyricLibrary } from "./lyric-library.mjs";
4
+
5
+ /** Background enrichment; rendering and model prompts never fetch lyrics. */
6
+ export async function createLyricService({ environment = process.env, library, provider = createLrclibProvider(), now = Date.now, throttleMs = 300 } = {}) {
7
+ const store = library ?? await openLyricLibrary({ environment });
8
+ let lastRefresh = { state: "idle", fetched: 0 };
9
+ return {
10
+ selectHome(seeds) { return store.selectHome(seeds); },
11
+ status() { return { ...store.status(), refresh: lastRefresh }; },
12
+ async refresh({ tracks }, { signal, onUpdate = () => {} } = {}) {
13
+ if (environment.MOONDOG_LYRICS === "off") return lastRefresh = { state: "offline", fetched: 0 };
14
+ if (store.retryAt() > now()) return lastRefresh = { state: "deferred", fetched: 0 };
15
+ const boundedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
16
+ lastRefresh = { state: "refreshing", fetched: 0 };
17
+ try {
18
+ for (const track of tracks) {
19
+ boundedSignal.throwIfAborted();
20
+ const cached = store.get(track);
21
+ const ttl = cached?.state === "ready" || cached?.state === "instrumental" ? 30 * 86_400_000 : 7 * 86_400_000;
22
+ if (cached && now() - cached.fetchedAt < ttl) continue;
23
+ if (lastRefresh.fetched >= 12) break;
24
+ // Requests remain sequential, including batches started in later sessions.
25
+ await delay(throttleMs, undefined, { signal: boundedSignal });
26
+ const record = await provider.lookup(track, { signal: boundedSignal });
27
+ boundedSignal.throwIfAborted();
28
+ store.put(track, record, now());
29
+ lastRefresh.fetched++;
30
+ onUpdate();
31
+ }
32
+ lastRefresh.state = "ready";
33
+ } catch (error) {
34
+ lastRefresh.state = boundedSignal.aborted ? "cancelled" : "unavailable";
35
+ if (!boundedSignal.aborted) store.deferUntil(now() + Math.max(60_000, error.retryAfterMs ?? 60_000));
36
+ }
37
+ return { ...lastRefresh };
38
+ },
39
+ close() { if (!library) store.close(); },
40
+ };
41
+ }