@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,290 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ readdir,
9
+ realpath,
10
+ rename,
11
+ rm,
12
+ } from "node:fs/promises";
13
+ import path from "node:path";
14
+
15
+ import {
16
+ canonicalizeJson,
17
+ findUnsafePersistedData,
18
+ } from "../../../scripts/contract-semantics.mjs";
19
+ import { validateAppleMusicImportRecords } from "./normalize.mjs";
20
+ import { AppleMusicImportError } from "./parse-plist.mjs";
21
+ import { isUuid } from "./stable-ids.mjs";
22
+
23
+ const dataFileNames = [
24
+ "track-refs.ndjson",
25
+ "track-snapshots.ndjson",
26
+ "warnings.ndjson",
27
+ ];
28
+
29
+ function fail(code, message, options) {
30
+ throw new AppleMusicImportError(code, message, options);
31
+ }
32
+
33
+ function sha256(buffer) {
34
+ return createHash("sha256").update(buffer).digest("hex");
35
+ }
36
+
37
+ function ndjson(records) {
38
+ if (records.length === 0) return Buffer.alloc(0);
39
+ return Buffer.from(`${records.map(canonicalizeJson).join("\n")}\n`, "utf8");
40
+ }
41
+
42
+ function buildFiles(records) {
43
+ const fileRecords = new Map([
44
+ ["track-refs.ndjson", records.trackRefs],
45
+ ["track-snapshots.ndjson", records.trackSnapshots],
46
+ ["warnings.ndjson", records.warnings],
47
+ ]);
48
+
49
+ return dataFileNames.map((name) => {
50
+ const buffer = ndjson(fileRecords.get(name));
51
+ return {
52
+ name,
53
+ buffer,
54
+ descriptor: {
55
+ name,
56
+ sha256: sha256(buffer),
57
+ bytes: buffer.length,
58
+ records: fileRecords.get(name).length,
59
+ },
60
+ };
61
+ });
62
+ }
63
+
64
+ async function pathType(target) {
65
+ try {
66
+ return await lstat(target);
67
+ } catch (cause) {
68
+ if (cause?.code === "ENOENT") return null;
69
+ throw cause;
70
+ }
71
+ }
72
+
73
+ function isPathInside(parent, candidate) {
74
+ const relative = path.relative(parent, candidate);
75
+ return (
76
+ relative !== "" &&
77
+ relative !== ".." &&
78
+ !relative.startsWith(`..${path.sep}`) &&
79
+ !path.isAbsolute(relative)
80
+ );
81
+ }
82
+
83
+ async function prepareOutputRoot(outputRoot, boundaryRoot) {
84
+ if (typeof boundaryRoot !== "string" || boundaryRoot.trim() === "") {
85
+ throw new TypeError("Import output boundary is required");
86
+ }
87
+ const lexicalBoundary = path.resolve(boundaryRoot);
88
+ const lexicalOutput = path.resolve(outputRoot);
89
+ if (!isPathInside(lexicalBoundary, lexicalOutput)) {
90
+ fail("output_boundary_invalid", "Import output root is outside its boundary");
91
+ }
92
+
93
+ const boundaryMetadata = await pathType(lexicalBoundary);
94
+ if (
95
+ !boundaryMetadata?.isDirectory() ||
96
+ boundaryMetadata.isSymbolicLink()
97
+ ) {
98
+ fail("output_boundary_invalid", "Import output boundary must be a real directory");
99
+ }
100
+ const canonicalBoundary = await realpath(lexicalBoundary);
101
+ const relativeSegments = path
102
+ .relative(lexicalBoundary, lexicalOutput)
103
+ .split(path.sep)
104
+ .filter(Boolean);
105
+ let current = lexicalBoundary;
106
+
107
+ for (const segment of relativeSegments) {
108
+ current = path.join(current, segment);
109
+ let metadata = await pathType(current);
110
+ if (!metadata) {
111
+ try {
112
+ await mkdir(current, { mode: 0o700 });
113
+ } catch (cause) {
114
+ if (cause?.code !== "EEXIST") throw cause;
115
+ }
116
+ metadata = await pathType(current);
117
+ }
118
+ if (!metadata?.isDirectory() || metadata.isSymbolicLink()) {
119
+ fail(
120
+ "output_path_symlink_forbidden",
121
+ "Import output path cannot contain symbolic links",
122
+ );
123
+ }
124
+ const canonicalCurrent = await realpath(current);
125
+ if (!isPathInside(canonicalBoundary, canonicalCurrent)) {
126
+ fail("output_boundary_invalid", "Import output path escaped its boundary");
127
+ }
128
+ }
129
+
130
+ await chmod(lexicalOutput, 0o700);
131
+ return realpath(lexicalOutput);
132
+ }
133
+
134
+ async function writePrivateFile(filePath, buffer) {
135
+ const handle = await open(filePath, "wx", 0o600);
136
+ try {
137
+ await handle.writeFile(buffer);
138
+ await handle.sync();
139
+ } finally {
140
+ await handle.close();
141
+ }
142
+ await chmod(filePath, 0o600);
143
+ }
144
+
145
+ async function verifyExistingBatch(
146
+ batchDirectory,
147
+ expectedManifestBuffer,
148
+ files,
149
+ ) {
150
+ const directoryMetadata = await pathType(batchDirectory);
151
+ if (!directoryMetadata?.isDirectory() || directoryMetadata.isSymbolicLink()) {
152
+ fail(
153
+ "existing_batch_conflict",
154
+ "Existing import batch path is not a private batch directory",
155
+ );
156
+ }
157
+ if (
158
+ process.platform !== "win32" &&
159
+ (directoryMetadata.mode & 0o077) !== 0
160
+ ) {
161
+ fail("existing_batch_conflict", "Existing batch permissions are not private");
162
+ }
163
+ const expectedNames = [
164
+ "manifest.json",
165
+ ...files.map((file) => file.name),
166
+ ].sort();
167
+ const actualNames = (await readdir(batchDirectory)).sort();
168
+ if (canonicalizeJson(actualNames) !== canonicalizeJson(expectedNames)) {
169
+ fail("existing_batch_conflict", "Existing batch contains unexpected files");
170
+ }
171
+
172
+ try {
173
+ const manifestPath = path.join(batchDirectory, "manifest.json");
174
+ const manifestMetadata = await lstat(manifestPath);
175
+ if (
176
+ !manifestMetadata.isFile() ||
177
+ manifestMetadata.isSymbolicLink() ||
178
+ manifestMetadata.size !== expectedManifestBuffer.length ||
179
+ (process.platform !== "win32" &&
180
+ (manifestMetadata.mode & 0o077) !== 0)
181
+ ) {
182
+ fail("existing_batch_conflict", "Existing batch manifest is invalid");
183
+ }
184
+ const existingManifestBuffer = await readFile(manifestPath);
185
+ if (!existingManifestBuffer.equals(expectedManifestBuffer)) {
186
+ fail("existing_batch_conflict", "Existing batch manifest does not match");
187
+ }
188
+ } catch (cause) {
189
+ if (cause instanceof AppleMusicImportError) throw cause;
190
+ fail("existing_batch_conflict", "Existing batch manifest cannot be verified", {
191
+ cause,
192
+ });
193
+ }
194
+
195
+ for (const file of files) {
196
+ const filePath = path.join(batchDirectory, file.name);
197
+ const metadata = await pathType(filePath);
198
+ if (
199
+ !metadata?.isFile() ||
200
+ metadata.isSymbolicLink() ||
201
+ metadata.size !== file.buffer.length ||
202
+ (process.platform !== "win32" && (metadata.mode & 0o077) !== 0)
203
+ ) {
204
+ fail("existing_batch_conflict", "Existing batch data file does not match");
205
+ }
206
+ if (sha256(await readFile(filePath)) !== file.descriptor.sha256) {
207
+ fail("existing_batch_conflict", "Existing batch data file does not match");
208
+ }
209
+ }
210
+ }
211
+
212
+ export async function writeAppleMusicImportBatch(
213
+ records,
214
+ { outputRoot, boundaryRoot },
215
+ ) {
216
+ if (!isUuid(records?.manifest?.import_batch_id)) {
217
+ fail("batch_id_invalid", "Import batch ID must be a UUID");
218
+ }
219
+ if (typeof outputRoot !== "string" || outputRoot.trim() === "") {
220
+ throw new TypeError("Import output root is required");
221
+ }
222
+
223
+ await validateAppleMusicImportRecords(records);
224
+ const files = buildFiles(records);
225
+ const manifest = {
226
+ ...records.manifest,
227
+ files: files.map(({ descriptor }) => descriptor),
228
+ };
229
+ if (findUnsafePersistedData(manifest).length > 0) {
230
+ fail("manifest_unsafe", "Import manifest failed persisted-data inspection");
231
+ }
232
+ const manifestBuffer = Buffer.from(
233
+ `${JSON.stringify(manifest, null, 2)}\n`,
234
+ "utf8",
235
+ );
236
+
237
+ const resolvedOutputRoot = await prepareOutputRoot(outputRoot, boundaryRoot);
238
+
239
+ const batchDirectoryName = records.manifest.import_batch_id;
240
+ const batchDirectory = path.join(resolvedOutputRoot, batchDirectoryName);
241
+ const existing = await pathType(batchDirectory);
242
+ if (existing) {
243
+ await verifyExistingBatch(batchDirectory, manifestBuffer, files);
244
+ return {
245
+ status: "unchanged",
246
+ batchDirectoryName,
247
+ manifest,
248
+ };
249
+ }
250
+
251
+ const temporaryDirectory = path.join(
252
+ resolvedOutputRoot,
253
+ `.${batchDirectoryName}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`,
254
+ );
255
+ await mkdir(temporaryDirectory, { mode: 0o700 });
256
+
257
+ try {
258
+ for (const file of files) {
259
+ await writePrivateFile(
260
+ path.join(temporaryDirectory, file.name),
261
+ file.buffer,
262
+ );
263
+ }
264
+ await writePrivateFile(
265
+ path.join(temporaryDirectory, "manifest.json"),
266
+ manifestBuffer,
267
+ );
268
+ await rename(temporaryDirectory, batchDirectory);
269
+ } catch (cause) {
270
+ await rm(temporaryDirectory, { recursive: true, force: true });
271
+ if (cause?.code === "EEXIST" || cause?.code === "ENOTEMPTY") {
272
+ await verifyExistingBatch(batchDirectory, manifestBuffer, files);
273
+ return {
274
+ status: "unchanged",
275
+ batchDirectoryName,
276
+ manifest,
277
+ };
278
+ }
279
+ if (cause instanceof AppleMusicImportError) throw cause;
280
+ fail("batch_write_failed", "Private import batch could not be written", {
281
+ cause,
282
+ });
283
+ }
284
+
285
+ return {
286
+ status: "written",
287
+ batchDirectoryName,
288
+ manifest,
289
+ };
290
+ }
@@ -0,0 +1,76 @@
1
+ import { readAppleMusicLibrary } from "./parse-plist.mjs";
2
+ import {
3
+ normalizeAppleMusicLibrary,
4
+ summarizeAppleMusicLibrary,
5
+ } from "./normalize.mjs";
6
+ import { writeAppleMusicImportBatch } from "./import-batch.mjs";
7
+
8
+ export {
9
+ AppleMusicImportError,
10
+ DEFAULT_APPLE_LIBRARY_LIMITS,
11
+ parseAppleMusicLibraryBuffer,
12
+ readAppleMusicLibrary,
13
+ } from "./parse-plist.mjs";
14
+ export {
15
+ normalizeAppleMusicLibrary,
16
+ summarizeAppleMusicLibrary,
17
+ } from "./normalize.mjs";
18
+ export { writeAppleMusicImportBatch } from "./import-batch.mjs";
19
+ export {
20
+ listAppleMusicImportBatches,
21
+ readAppleMusicImportBatch,
22
+ } from "./read-batch.mjs";
23
+ export {
24
+ APPLE_PROFILE_RULES,
25
+ APPLE_PROFILE_RULES_VERSION,
26
+ computeAppleProjectionInputDigest,
27
+ promoteAppleMusicImportBatches,
28
+ } from "./projection-records.mjs";
29
+ export {
30
+ APPLE_LIBRARY_SEARCH_HARD_LIMIT,
31
+ APPLE_PROFILE_SUMMARY_HARD_LIMIT,
32
+ APPLE_SQLITE_PROJECTION_VERSION,
33
+ computeAppleSqliteLogicalDigest,
34
+ defaultAppleMusicProjectionPath,
35
+ defaultAppleMusicProjectionPathFor,
36
+ legacyAppleMusicProjectionPath,
37
+ openAppleMusicSqliteProjection,
38
+ rebuildAppleMusicSqliteProjection,
39
+ } from "./sqlite-projection.mjs";
40
+ export {
41
+ APPLE_LIBRARY_BATCH_NAMESPACE,
42
+ APPLE_LIBRARY_CANONICAL_TRACK_NAMESPACE,
43
+ APPLE_LIBRARY_OBSERVATION_NAMESPACE,
44
+ APPLE_LIBRARY_PROFILE_EVIDENCE_NAMESPACE,
45
+ APPLE_LIBRARY_SNAPSHOT_NAMESPACE,
46
+ APPLE_LIBRARY_TRACK_NAMESPACE,
47
+ isUuid,
48
+ uuidV5,
49
+ } from "./stable-ids.mjs";
50
+
51
+ export async function inspectAppleMusicLibraryFile(inputPath, options = {}) {
52
+ const parsed = await readAppleMusicLibrary(inputPath, options);
53
+ return summarizeAppleMusicLibrary(parsed);
54
+ }
55
+
56
+ export async function buildAppleMusicLibraryImport(
57
+ inputPath,
58
+ { subjectId, limits } = {},
59
+ ) {
60
+ const parsed = await readAppleMusicLibrary(inputPath, { limits });
61
+ return normalizeAppleMusicLibrary(parsed, { subjectId });
62
+ }
63
+
64
+ export async function importAppleMusicLibraryFile(
65
+ inputPath,
66
+ { subjectId, outputRoot, outputBoundary, limits } = {},
67
+ ) {
68
+ const records = await buildAppleMusicLibraryImport(inputPath, {
69
+ subjectId,
70
+ limits,
71
+ });
72
+ return writeAppleMusicImportBatch(records, {
73
+ outputRoot,
74
+ boundaryRoot: outputBoundary,
75
+ });
76
+ }