@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,1827 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import {
4
+ readAppleMusicSourceStatus,
5
+ resolveAppleMusicImportsRoot,
6
+ } from "./apple-library-source-status.mjs";
7
+ import {
8
+ listAgentCapabilityDescriptors,
9
+ listCapabilities,
10
+ } from "./capability-catalog.mjs";
11
+ import { recoverArtistReleasesWithCrossCatalogIdentity } from "../integrations/cross-catalog-artist-identity.mjs";
12
+
13
+ const minimumNodeVersion = [22, 19, 0];
14
+
15
+ function spotifyResolutionError(code, message) {
16
+ const error = new Error(message);
17
+ error.code = code;
18
+ return error;
19
+ }
20
+
21
+ function spotifyPlaylistDetails({ name, description } = {}) {
22
+ if (
23
+ typeof name !== "string" ||
24
+ name.trim().length < 1 ||
25
+ Array.from(name).length > 100
26
+ ) {
27
+ throw spotifyResolutionError(
28
+ "invalid_playlist_name",
29
+ "The Spotify playlist name is invalid.",
30
+ );
31
+ }
32
+ if (
33
+ description !== undefined &&
34
+ (typeof description !== "string" || Array.from(description).length > 200)
35
+ ) {
36
+ throw spotifyResolutionError(
37
+ "invalid_playlist_description",
38
+ "The Spotify playlist description is invalid.",
39
+ );
40
+ }
41
+ return {
42
+ name: name.trim(),
43
+ ...(description !== undefined ? { description: description.trim() } : {}),
44
+ };
45
+ }
46
+
47
+ const requiredDomainServiceMethods = Object.freeze([
48
+ "searchLibrary",
49
+ "getProfileSummary",
50
+ "explainProfileEvidence",
51
+ "buildPlaylistPlan",
52
+ "getTrustedTracks",
53
+ "registerRetainedPlaylistCandidateSet",
54
+ "beginPrompt",
55
+ "endPrompt",
56
+ "resetCandidateSets",
57
+ ]);
58
+
59
+ const requiredProfileServiceMethods = Object.freeze([
60
+ "getProfileSummary",
61
+ "explainProfileEvidence",
62
+ ]);
63
+ const requiredPlaylistServiceMethods = Object.freeze([
64
+ "buildPlaylistPlan",
65
+ "getTrustedTracks",
66
+ "registerRetainedPlaylistCandidateSet",
67
+ "beginPrompt",
68
+ "endPrompt",
69
+ "resetCandidateSets",
70
+ ]);
71
+
72
+ const requiredMusicCatalogMethods = Object.freeze([
73
+ "findArtistReleases",
74
+ ]);
75
+ const requiredMusicSimilarityMethods = Object.freeze([
76
+ "discoverSimilarTracks",
77
+ ]);
78
+
79
+ function versionAtLeast(actual, minimum) {
80
+ const values = actual.split(".").map((part) => Number.parseInt(part, 10));
81
+ for (let index = 0; index < minimum.length; index += 1) {
82
+ const actualValue = values[index] ?? 0;
83
+ if (actualValue > minimum[index]) return true;
84
+ if (actualValue < minimum[index]) return false;
85
+ }
86
+ return true;
87
+ }
88
+
89
+ function normalizedArtistName(value) {
90
+ return typeof value === "string"
91
+ ? value.normalize("NFKC").toLocaleLowerCase("und").trim()
92
+ : "";
93
+ }
94
+
95
+ function exactArtistReleaseHint(searchResult, artistName) {
96
+ const normalizedArtist = normalizedArtistName(artistName);
97
+ if (!normalizedArtist || !Array.isArray(searchResult?.tracks)) {
98
+ return undefined;
99
+ }
100
+ const match = searchResult.tracks.find(
101
+ (track) =>
102
+ normalizedArtistName(track.artist_credit) === normalizedArtist &&
103
+ typeof track.release === "string" &&
104
+ track.release.trim().length > 0,
105
+ );
106
+ return match?.release.trim();
107
+ }
108
+
109
+ export class MoondogApplication {
110
+ constructor({
111
+ importsRoot = resolveAppleMusicImportsRoot(),
112
+ domainServices = null,
113
+ domainServicesError = null,
114
+ memoryStore = null,
115
+ memoryRouteKey = "local:main",
116
+ spotifyConnection = null,
117
+ musicCatalog = null,
118
+ musicSimilarity = null,
119
+ artistIdentityResolver = null,
120
+ webResearch = null,
121
+ } = {}) {
122
+ this.importsRoot = importsRoot;
123
+ this.domainServices = domainServices;
124
+ this.domainServicesError =
125
+ typeof domainServicesError === "string" &&
126
+ /^projection_[a-z0-9_]+$/u.test(domainServicesError)
127
+ ? domainServicesError
128
+ : null;
129
+ this.memoryStore = memoryStore;
130
+ this.memoryRouteKey = memoryRouteKey;
131
+ this.memorySession = null;
132
+ this.spotifyConnection = spotifyConnection;
133
+ this.musicCatalog = musicCatalog;
134
+ this.musicSimilarity = musicSimilarity;
135
+ this.artistIdentityResolver = artistIdentityResolver;
136
+ this.webResearch = webResearch;
137
+ this.spotifyResolutions = new Map();
138
+ this.spotifyPlaylistTargets = new Map();
139
+ this.spotifyPlaylistItems = new Map();
140
+ this.spotifyPlaylistSnapshots = new Map();
141
+ this.validatedPlaylistTrackRefs = null;
142
+ this.pendingSpotifyPlaylist = null;
143
+ this.pendingSpotifyPlaylistEdit = null;
144
+ this.pendingPlaylistRevisionCandidateSet = null;
145
+ this.promptDiscoverySources = [];
146
+ this.promptProfileSeed = null;
147
+ this.pendingPlaylistPromptTransaction = null;
148
+ }
149
+
150
+ domainServicesReady() {
151
+ return (
152
+ this.domainServices !== null &&
153
+ requiredDomainServiceMethods.every(
154
+ (method) => typeof this.domainServices[method] === "function",
155
+ )
156
+ );
157
+ }
158
+
159
+ profileServicesReady() {
160
+ return (
161
+ this.domainServices !== null &&
162
+ requiredProfileServiceMethods.every(
163
+ (method) => typeof this.domainServices[method] === "function",
164
+ )
165
+ );
166
+ }
167
+
168
+ playlistServicesReady() {
169
+ return (
170
+ this.domainServices !== null &&
171
+ requiredPlaylistServiceMethods.every(
172
+ (method) => typeof this.domainServices[method] === "function",
173
+ ) &&
174
+ (typeof this.domainServices.playlistReady !== "function" ||
175
+ this.domainServices.playlistReady() === true)
176
+ );
177
+ }
178
+
179
+ rediscoveryServicesReady() {
180
+ return (
181
+ this.playlistServicesReady() &&
182
+ typeof this.domainServices.getRediscoveryCandidates === "function" &&
183
+ typeof this.domainServices.rediscoveryReady === "function" &&
184
+ this.domainServices.rediscoveryReady() === true
185
+ );
186
+ }
187
+
188
+ historicalReturnServicesReady() {
189
+ return (
190
+ this.playlistServicesReady() &&
191
+ typeof this.domainServices.getHistoricalReturnCandidates ===
192
+ "function" &&
193
+ typeof this.domainServices.historicalReturnReady === "function" &&
194
+ this.domainServices.historicalReturnReady() === true
195
+ );
196
+ }
197
+
198
+ timeCapsuleServicesReady() {
199
+ return (
200
+ this.playlistServicesReady() &&
201
+ typeof this.domainServices.getTimeCapsuleCandidates === "function" &&
202
+ typeof this.domainServices.timeCapsuleReady === "function" &&
203
+ this.domainServices.timeCapsuleReady() === true
204
+ );
205
+ }
206
+
207
+ backToBackServicesReady() {
208
+ return (
209
+ this.playlistServicesReady() &&
210
+ typeof this.domainServices.getBackToBackCandidates === "function" &&
211
+ typeof this.domainServices.backToBackReady === "function" &&
212
+ this.domainServices.backToBackReady() === true
213
+ );
214
+ }
215
+
216
+ requireDomainServices() {
217
+ if (!this.domainServicesReady()) {
218
+ throw new Error(
219
+ "Moondog's library and profile projection is not ready. Rebuild the local projection first.",
220
+ );
221
+ }
222
+ return this.domainServices;
223
+ }
224
+
225
+ requireProfileServices() {
226
+ if (!this.profileServicesReady()) {
227
+ throw new Error(
228
+ "Moondog's profile projection is not ready. Import Spotify history or rebuild the local Apple projection first.",
229
+ );
230
+ }
231
+ return this.domainServices;
232
+ }
233
+
234
+ requirePlaylistServices() {
235
+ if (!this.playlistServicesReady()) {
236
+ throw new Error("Moondog's trusted playlist-planning path is not ready.");
237
+ }
238
+ return this.domainServices;
239
+ }
240
+
241
+ requireRediscoveryServices() {
242
+ if (!this.rediscoveryServicesReady()) {
243
+ throw new Error(
244
+ "Moondog's private listening-history rediscovery path is not ready.",
245
+ );
246
+ }
247
+ return this.domainServices;
248
+ }
249
+
250
+ requireHistoricalReturnServices() {
251
+ if (!this.historicalReturnServicesReady()) {
252
+ throw new Error(
253
+ "Moondog's private listening-history return path is not ready.",
254
+ );
255
+ }
256
+ return this.domainServices;
257
+ }
258
+
259
+ requireTimeCapsuleServices() {
260
+ if (!this.timeCapsuleServicesReady()) {
261
+ throw new Error(
262
+ "Moondog's private listening-history Time Machine path is not ready.",
263
+ );
264
+ }
265
+ return this.domainServices;
266
+ }
267
+
268
+ requireBackToBackServices() {
269
+ if (!this.backToBackServicesReady()) {
270
+ throw new Error(
271
+ "Moondog's private listening-history back-to-back path is not ready.",
272
+ );
273
+ }
274
+ return this.domainServices;
275
+ }
276
+
277
+ musicCatalogReady() {
278
+ return (
279
+ this.musicCatalog !== null &&
280
+ requiredMusicCatalogMethods.every(
281
+ (method) => typeof this.musicCatalog[method] === "function",
282
+ )
283
+ );
284
+ }
285
+
286
+ requireMusicCatalog() {
287
+ if (!this.musicCatalogReady()) {
288
+ throw new Error("Moondog's external music catalog is not ready.");
289
+ }
290
+ return this.musicCatalog;
291
+ }
292
+
293
+ musicDiscoveryReady() {
294
+ return (
295
+ this.musicCatalogReady() &&
296
+ this.profileServicesReady() &&
297
+ this.playlistServicesReady() &&
298
+ typeof this.musicCatalog.searchTracks === "function" &&
299
+ typeof this.domainServices.registerExternalCandidateSet === "function"
300
+ );
301
+ }
302
+
303
+ requireMusicDiscovery() {
304
+ if (!this.musicDiscoveryReady()) {
305
+ throw new Error(
306
+ "Moondog's external music discovery path is not ready.",
307
+ );
308
+ }
309
+ return {
310
+ catalog: this.musicCatalog,
311
+ domainServices: this.domainServices,
312
+ };
313
+ }
314
+
315
+ musicSimilarityReady() {
316
+ return (
317
+ this.musicSimilarity !== null &&
318
+ this.profileServicesReady() &&
319
+ this.playlistServicesReady() &&
320
+ requiredMusicSimilarityMethods.every(
321
+ (method) => typeof this.musicSimilarity[method] === "function",
322
+ ) &&
323
+ typeof this.domainServices.registerExternalCandidateSet === "function"
324
+ );
325
+ }
326
+
327
+ requireMusicSimilarity() {
328
+ if (!this.musicSimilarityReady()) {
329
+ throw new Error(
330
+ "Moondog's open artist similarity path is not ready.",
331
+ );
332
+ }
333
+ return {
334
+ similarity: this.musicSimilarity,
335
+ domainServices: this.domainServices,
336
+ };
337
+ }
338
+
339
+ async sourceStatus() {
340
+ const source = await readAppleMusicSourceStatus(this.importsRoot);
341
+ if (source.latest && this.profileServicesReady()) {
342
+ source.semantics.profile_materialization_ready = true;
343
+ }
344
+ return source;
345
+ }
346
+
347
+ async profileStatus() {
348
+ if (
349
+ this.profileServicesReady() &&
350
+ typeof this.domainServices.profileStatus === "function"
351
+ ) {
352
+ return this.domainServices.profileStatus();
353
+ }
354
+ const source = await this.sourceStatus();
355
+ const projectionInvalid =
356
+ this.domainServicesError !== null &&
357
+ this.domainServicesError !== "projection_not_built";
358
+ return {
359
+ state: projectionInvalid ? "invalid" : "not_materialized",
360
+ source_state: source.state,
361
+ evidence_records: 0,
362
+ claims: 0,
363
+ ...(this.domainServicesError
364
+ ? { error: this.domainServicesError }
365
+ : {}),
366
+ reason:
367
+ projectionInvalid
368
+ ? "The local ProfileProjection is unavailable or invalid. Rebuild it from the verified Apple import batch before using library-aware tools."
369
+ : "The verified Apple library snapshot is canonical input, but its disposable ProfileProjection has not been built yet. Run the local projection rebuild before using library-aware tools.",
370
+ next_contracts: [
371
+ "library_track_observation",
372
+ "profile_evidence_v2",
373
+ "sqlite_projection",
374
+ ],
375
+ };
376
+ }
377
+
378
+ memoryStatus() {
379
+ if (this.memoryStore) {
380
+ const status = this.memoryStore.status();
381
+ const reflection = this.memoryStore.reflectionStatus();
382
+ return {
383
+ state: status.state,
384
+ conversation_transcript: "local_sqlite",
385
+ long_term_memory: "explicit_revisable_claims",
386
+ music_profile_is_separate: true,
387
+ sessions: status.sessions,
388
+ turns: status.turns,
389
+ episodes: status.episodes,
390
+ active_memories: status.active_memories,
391
+ reflection,
392
+ policy:
393
+ "Completed dialogue becomes short-term episodes. The background Memory Agent may promote high-confidence explicit general assertions into durable claims. Inferred claims and music taste remain reviewable candidates for the long-term memory and Profile pipelines.",
394
+ };
395
+ }
396
+ return {
397
+ state: "not_persistent",
398
+ conversation_transcript: "process_local_only",
399
+ long_term_memory: "not_implemented",
400
+ music_profile_is_separate: true,
401
+ policy:
402
+ "A conversation summary does not become long-term memory without an explicit user assertion or confirmation.",
403
+ };
404
+ }
405
+
406
+ memorySummary({ query, limit = 12 } = {}) {
407
+ const status = this.memoryStatus();
408
+ if (!this.memoryStore) {
409
+ return {
410
+ ...status,
411
+ memories: [],
412
+ recent_episodes: [],
413
+ recent_sessions: [],
414
+ };
415
+ }
416
+ const session = this.ensureMemorySession();
417
+ return {
418
+ ...status,
419
+ memories: this.memoryStore.listMemories({ query, limit }),
420
+ recent_episodes: this.memoryStore.recentEpisodes({
421
+ query,
422
+ limit,
423
+ }),
424
+ recent_sessions: this.memoryStore.recentSessions({
425
+ excludeSessionId: session.session_id,
426
+ limit: 2,
427
+ turnsPerSession: 4,
428
+ }),
429
+ };
430
+ }
431
+
432
+ memoryContext(query) {
433
+ if (!this.memoryStore) {
434
+ return {
435
+ state: "not_persistent",
436
+ durable_memories: [],
437
+ relevant_memories: [],
438
+ recent_episodes: [],
439
+ recent_sessions: [],
440
+ };
441
+ }
442
+ const session = this.ensureMemorySession();
443
+ return {
444
+ state: "ready",
445
+ ...this.memoryStore.context({
446
+ query,
447
+ currentSessionId: session.session_id,
448
+ }),
449
+ };
450
+ }
451
+
452
+ rememberMemory({ text, kind, horizon, origin = "explicit_user" }) {
453
+ if (!this.memoryStore) throw new Error("Persistent memory is unavailable");
454
+ const session = this.ensureMemorySession();
455
+ return this.memoryStore.remember({
456
+ text,
457
+ kind,
458
+ horizon,
459
+ origin,
460
+ sourceSessionId: session.session_id,
461
+ });
462
+ }
463
+
464
+ pendingMemoryReflection(options) {
465
+ if (!this.memoryStore) throw new Error("Persistent memory is unavailable");
466
+ return this.memoryStore.pendingReflectionEpisodes(options);
467
+ }
468
+
469
+ beginMemoryReflection(options) {
470
+ if (!this.memoryStore) throw new Error("Persistent memory is unavailable");
471
+ return this.memoryStore.beginReflection(options);
472
+ }
473
+
474
+ completeMemoryReflection(options) {
475
+ if (!this.memoryStore) throw new Error("Persistent memory is unavailable");
476
+ return this.memoryStore.completeReflection(options);
477
+ }
478
+
479
+ failMemoryReflection(runId, leaseToken, errorCode) {
480
+ if (!this.memoryStore) throw new Error("Persistent memory is unavailable");
481
+ return this.memoryStore.failReflection(runId, leaseToken, errorCode);
482
+ }
483
+
484
+ memoryReflectionStatus() {
485
+ if (!this.memoryStore) {
486
+ return {
487
+ state: "unavailable",
488
+ pending_episodes: 0,
489
+ candidate_episodes: 0,
490
+ last_run: null,
491
+ };
492
+ }
493
+ return this.memoryStore.reflectionStatus();
494
+ }
495
+
496
+ forgetMemory(memoryId) {
497
+ if (!this.memoryStore) throw new Error("Persistent memory is unavailable");
498
+ const session = this.ensureMemorySession();
499
+ return this.memoryStore.forget(memoryId, {
500
+ sourceSessionId: session.session_id,
501
+ });
502
+ }
503
+
504
+ prepareRememberMemory({ text, kind, horizon, origin }) {
505
+ if (!this.memoryStore) throw new Error("Persistent memory is unavailable");
506
+ const session = this.ensureMemorySession();
507
+ return this.memoryStore.prepareRemember({
508
+ text,
509
+ kind,
510
+ horizon,
511
+ origin,
512
+ sourceSessionId: session.session_id,
513
+ });
514
+ }
515
+
516
+ prepareForgetMemory(memoryId) {
517
+ if (!this.memoryStore) throw new Error("Persistent memory is unavailable");
518
+ const session = this.ensureMemorySession();
519
+ return this.memoryStore.prepareForget(memoryId, {
520
+ sourceSessionId: session.session_id,
521
+ });
522
+ }
523
+
524
+ commitCompletedPrompt(user, assistant, memoryMutations = []) {
525
+ if (!this.memoryStore) return { recorded: false, memory_results: [] };
526
+ const session = this.ensureMemorySession();
527
+ return this.memoryStore.commitCompletedPrompt(session.session_id, {
528
+ user,
529
+ assistant,
530
+ memoryMutations,
531
+ });
532
+ }
533
+
534
+ recordCompletedTurn(user, assistant) {
535
+ return this.commitCompletedPrompt(user, assistant);
536
+ }
537
+
538
+ #clearConversationState() {
539
+ this.pendingSpotifyPlaylist = null;
540
+ this.pendingSpotifyPlaylistEdit = null;
541
+ this.pendingPlaylistRevisionCandidateSet = null;
542
+ this.promptDiscoverySources = [];
543
+ this.pendingPlaylistPromptTransaction = null;
544
+ this.resetSpotifyResolutions();
545
+ this.resetSpotifyPlaylistInspection();
546
+ this.domainServices?.resetCandidateSets?.();
547
+ }
548
+
549
+ startNewSession(reason = "new_session") {
550
+ this.memorySession = this.memoryStore
551
+ ? this.memoryStore.rotateSession(this.memoryRouteKey, reason)
552
+ : null;
553
+ this.#clearConversationState();
554
+ return structuredClone(this.memorySession);
555
+ }
556
+
557
+ listSavedSessions({ limit = 200 } = {}) {
558
+ if (!this.memoryStore) return [];
559
+ return this.memoryStore.listSessions({
560
+ routeKey: this.memoryRouteKey,
561
+ limit,
562
+ });
563
+ }
564
+
565
+ resumeSession(sessionId) {
566
+ if (!this.memoryStore) throw new Error("Persistent memory is unavailable");
567
+ this.memorySession = this.memoryStore.resumeSession(sessionId, {
568
+ routeKey: this.memoryRouteKey,
569
+ });
570
+ this.#clearConversationState();
571
+ return structuredClone(this.memorySession);
572
+ }
573
+
574
+ ensureMemorySession() {
575
+ if (!this.memoryStore) return null;
576
+ this.memorySession ??= this.memoryStore.resumeOrStartSession(
577
+ this.memoryRouteKey,
578
+ );
579
+ return this.memorySession;
580
+ }
581
+
582
+ currentSessionTurns({ limit = 40 } = {}) {
583
+ if (!this.memoryStore) return [];
584
+ const session = this.ensureMemorySession();
585
+ return this.memoryStore.readSessionTurns(session.session_id, { limit });
586
+ }
587
+
588
+ setSpotifyConnection(connection) {
589
+ this.spotifyConnection = connection;
590
+ return this.spotifyStatus();
591
+ }
592
+
593
+ spotifyReady() {
594
+ return Boolean(
595
+ this.spotifyConnection?.ready?.() && this.spotifyConnection?.service,
596
+ );
597
+ }
598
+
599
+ spotifyStatus() {
600
+ return this.spotifyConnection?.publicStatus?.() ?? {
601
+ provider: "spotify",
602
+ state: "not_configured",
603
+ reason: "client_id_required",
604
+ authentication: "not_configured",
605
+ client_id_configured: false,
606
+ external_effects: "disabled",
607
+ };
608
+ }
609
+
610
+ requireSpotifyService() {
611
+ if (!this.spotifyReady()) {
612
+ throw new Error(
613
+ "Spotify control is not ready. Configure a client ID and run moondog spotify login first.",
614
+ );
615
+ }
616
+ return this.spotifyConnection.service;
617
+ }
618
+
619
+ async spotifyPlayerStatus() {
620
+ const player = await this.requireSpotifyService().currentPlayer();
621
+ if (player.state !== "available") {
622
+ return { provider: "spotify", state: "inactive" };
623
+ }
624
+ return {
625
+ provider: "spotify",
626
+ state: "available",
627
+ is_playing: player.is_playing === true,
628
+ shuffle_state: player.shuffle_state === true,
629
+ repeat_state: player.repeat_state,
630
+ currently_playing_type: player.currently_playing_type,
631
+ active_device: Boolean(player.device?.is_active),
632
+ restricted_device: player.device?.is_restricted === true,
633
+ item_available: Boolean(player.item),
634
+ };
635
+ }
636
+
637
+ spotifyControl({ action, ...parameters }) {
638
+ const service = this.requireSpotifyService();
639
+ if (action === "resume" && Array.isArray(parameters.trackRefs)) {
640
+ const { trackRefs, ...playbackParameters } = parameters;
641
+ const uris = this.requireSpotifyTrackUris(trackRefs);
642
+ return service.resume({
643
+ ...playbackParameters,
644
+ uris,
645
+ });
646
+ }
647
+ switch (action) {
648
+ case "resume":
649
+ return service.resume(parameters);
650
+ case "pause":
651
+ return service.pause(parameters);
652
+ case "next":
653
+ return service.next(parameters);
654
+ case "previous":
655
+ return service.previous(parameters);
656
+ case "volume":
657
+ return service.setVolume(parameters);
658
+ case "seek":
659
+ return service.seek(parameters);
660
+ case "shuffle":
661
+ return service.setShuffle(parameters);
662
+ case "repeat":
663
+ return service.setRepeat(parameters);
664
+ default:
665
+ throw new Error("Unsupported Spotify player action.");
666
+ }
667
+ }
668
+
669
+ spotifyAddToQueue(input) {
670
+ if (input?.trackRefId !== undefined) {
671
+ const resolution = this.requireSpotifyResolution(input.trackRefId);
672
+ return this.requireSpotifyService().addToQueue({
673
+ uri: resolution.uri,
674
+ ...(input.deviceId ? { deviceId: input.deviceId } : {}),
675
+ });
676
+ }
677
+ return this.requireSpotifyService().addToQueue(input);
678
+ }
679
+
680
+ spotifyTransfer(input) {
681
+ return this.requireSpotifyService().transfer(input);
682
+ }
683
+
684
+ requireSpotifyResolver() {
685
+ const connection = this.spotifyConnection;
686
+ if (!this.spotifyReady() || !connection?.resolver) {
687
+ throw spotifyResolutionError(
688
+ "spotify_resolver_unavailable",
689
+ "Spotify catalog resolution is not ready. Configure a client ID and run moondog spotify login first.",
690
+ );
691
+ }
692
+ return connection.resolver;
693
+ }
694
+
695
+ requireSpotifyScopes(requiredScopes, code = "spotify_scopes_missing") {
696
+ const connection = this.spotifyConnection;
697
+ const unavailable = new Set(connection?.missingScopes?.() ?? []);
698
+ const missing = requiredScopes.filter((scope) => unavailable.has(scope));
699
+ if (missing.length > 0) {
700
+ throw spotifyResolutionError(
701
+ code,
702
+ `Spotify authorization is missing scopes: ${missing.join(", ")}. Run moondog spotify login again to grant them.`,
703
+ );
704
+ }
705
+ }
706
+
707
+ requireSpotifyWriteScopes(requiredScopes) {
708
+ this.requireSpotifyScopes(requiredScopes, "spotify_write_scopes_missing");
709
+ }
710
+
711
+ resetSpotifyResolutions() {
712
+ const invalidated = this.spotifyResolutions.size;
713
+ this.spotifyResolutions.clear();
714
+ this.validatedPlaylistTrackRefs = null;
715
+ return { invalidated_spotify_resolutions: invalidated };
716
+ }
717
+
718
+ resetSpotifyPlaylistInspection() {
719
+ const invalidated =
720
+ this.spotifyPlaylistTargets.size +
721
+ this.spotifyPlaylistItems.size +
722
+ this.spotifyPlaylistSnapshots.size;
723
+ this.spotifyPlaylistTargets.clear();
724
+ this.spotifyPlaylistItems.clear();
725
+ this.spotifyPlaylistSnapshots.clear();
726
+ return { invalidated_spotify_playlist_refs: invalidated };
727
+ }
728
+
729
+ requireSpotifyPlaylistTarget(playlistRefId) {
730
+ const target = this.spotifyPlaylistTargets.get(playlistRefId);
731
+ if (!target) {
732
+ throw spotifyResolutionError(
733
+ "spotify_playlist_not_inspected",
734
+ "The Spotify playlist reference is not active in this prompt. List and inspect the playlist first.",
735
+ );
736
+ }
737
+ return target;
738
+ }
739
+
740
+ async spotifyListEditablePlaylists({ limit, offset } = {}) {
741
+ this.requireSpotifyScopes(["user-read-private", "playlist-read-private"]);
742
+ const result = await this.requireSpotifyService().editablePlaylists({
743
+ ...(limit !== undefined ? { limit } : {}),
744
+ ...(offset !== undefined ? { offset } : {}),
745
+ });
746
+ const playlists = [];
747
+ for (const playlist of result.playlists ?? []) {
748
+ const playlistRefId = randomUUID();
749
+ this.spotifyPlaylistTargets.set(playlistRefId, {
750
+ playlistId: playlist.playlist_id,
751
+ name: playlist.name,
752
+ trackCount: playlist.track_count,
753
+ });
754
+ playlists.push({
755
+ playlist_ref_id: playlistRefId,
756
+ name: playlist.name,
757
+ track_count: playlist.track_count,
758
+ });
759
+ }
760
+ return {
761
+ provider: "spotify",
762
+ playlists,
763
+ excluded: structuredClone(result.excluded),
764
+ has_more: result.has_more === true,
765
+ next_offset: result.next_offset ?? null,
766
+ expires_on: "prompt_end",
767
+ };
768
+ }
769
+
770
+ async spotifyInspectPlaylist({ playlistRefId } = {}) {
771
+ this.requireSpotifyScopes(["user-read-private", "playlist-read-private"]);
772
+ const target = this.requireSpotifyPlaylistTarget(playlistRefId);
773
+ const snapshot = await this.requireSpotifyService().playlistSnapshot({
774
+ playlistId: target.playlistId,
775
+ });
776
+ if (snapshot.playlist?.playlist_id !== target.playlistId) {
777
+ throw spotifyResolutionError(
778
+ "spotify_playlist_identity_changed",
779
+ "Spotify returned a different playlist identity. List the playlist again before editing.",
780
+ );
781
+ }
782
+ const items = snapshot.items.map((item, index) => {
783
+ const playlistItemRefId = randomUUID();
784
+ const safeItem = {
785
+ playlist_item_ref_id: playlistItemRefId,
786
+ position: index + 1,
787
+ title: item.title,
788
+ artists: [...item.artists],
789
+ ...(item.album ? { album: item.album } : {}),
790
+ ...(Number.isInteger(item.duration_ms)
791
+ ? { duration_ms: item.duration_ms }
792
+ : {}),
793
+ };
794
+ this.spotifyPlaylistItems.set(playlistItemRefId, {
795
+ playlistRefId,
796
+ uri: item.uri,
797
+ position: index + 1,
798
+ metadata: structuredClone(safeItem),
799
+ });
800
+ return safeItem;
801
+ });
802
+ this.spotifyPlaylistSnapshots.set(playlistRefId, {
803
+ playlistId: target.playlistId,
804
+ snapshotId: snapshot.playlist.snapshot_id,
805
+ name: snapshot.playlist.name,
806
+ items: items.map((item) => ({
807
+ playlistItemRefId: item.playlist_item_ref_id,
808
+ uri: this.spotifyPlaylistItems.get(item.playlist_item_ref_id).uri,
809
+ position: item.position,
810
+ metadata: structuredClone(item),
811
+ })),
812
+ });
813
+ return {
814
+ provider: "spotify",
815
+ playlist: {
816
+ playlist_ref_id: playlistRefId,
817
+ name: snapshot.playlist.name,
818
+ track_count: items.length,
819
+ is_public: false,
820
+ },
821
+ items,
822
+ expires_on: "prompt_end",
823
+ edit_boundary:
824
+ "Preview only in this prompt. An explicit confirmation in a later prompt is required before Spotify changes.",
825
+ };
826
+ }
827
+
828
+ requireSpotifyResolution(trackRefId) {
829
+ const resolution = this.spotifyResolutions.get(trackRefId);
830
+ if (!resolution) {
831
+ throw spotifyResolutionError(
832
+ "spotify_track_not_resolved",
833
+ "The track was not resolved to a Spotify catalog identity in this prompt. Resolve it with the Spotify resolve tool first.",
834
+ );
835
+ }
836
+ return resolution;
837
+ }
838
+
839
+ requireSpotifyTrackUris(trackRefIds) {
840
+ if (
841
+ !Array.isArray(trackRefIds) ||
842
+ trackRefIds.length < 1 ||
843
+ trackRefIds.length > 12
844
+ ) {
845
+ throw spotifyResolutionError(
846
+ "invalid_spotify_track_refs",
847
+ "Spotify track references are invalid.",
848
+ );
849
+ }
850
+ const seen = new Set();
851
+ const uris = [];
852
+ for (const trackRefId of trackRefIds) {
853
+ if (
854
+ typeof trackRefId !== "string" ||
855
+ trackRefId.length < 1 ||
856
+ trackRefId.length > 128 ||
857
+ seen.has(trackRefId)
858
+ ) {
859
+ throw spotifyResolutionError(
860
+ "invalid_spotify_track_refs",
861
+ "Spotify track references are invalid.",
862
+ );
863
+ }
864
+ seen.add(trackRefId);
865
+ uris.push(this.requireSpotifyResolution(trackRefId).uri);
866
+ }
867
+ return uris;
868
+ }
869
+
870
+ async spotifyResolveTracks({ trackRefs } = {}) {
871
+ if (
872
+ !Array.isArray(trackRefs) ||
873
+ trackRefs.length < 1 ||
874
+ trackRefs.length > 12 ||
875
+ trackRefs.some(
876
+ (trackRefId) =>
877
+ typeof trackRefId !== "string" ||
878
+ trackRefId.length < 1 ||
879
+ trackRefId.length > 128,
880
+ )
881
+ ) {
882
+ throw spotifyResolutionError(
883
+ "invalid_spotify_track_refs",
884
+ "Spotify resolution track references are invalid.",
885
+ );
886
+ }
887
+ this.requireSpotifyScopes(["user-read-private"]);
888
+ const domainServices = this.requirePlaylistServices();
889
+ const trustedTracks = domainServices.getTrustedTracks(trackRefs);
890
+ const resolver = this.requireSpotifyResolver();
891
+ const result = await resolver.resolve(trustedTracks);
892
+ const resolutions = [];
893
+ for (const resolution of result.resolutions) {
894
+ if (resolution.status === "resolved" && resolution.spotify) {
895
+ this.spotifyResolutions.set(resolution.track_ref_id, {
896
+ track_id: resolution.spotify.track_id,
897
+ uri: resolution.spotify.uri,
898
+ ...(resolution.matched
899
+ ? { matched: structuredClone(resolution.matched) }
900
+ : {}),
901
+ });
902
+ }
903
+ const modelFacing = {
904
+ track_ref_id: resolution.track_ref_id,
905
+ status: resolution.status,
906
+ };
907
+ if (resolution.match_quality) {
908
+ modelFacing.match_quality = resolution.match_quality;
909
+ }
910
+ if (resolution.matched) modelFacing.matched = resolution.matched;
911
+ resolutions.push(modelFacing);
912
+ }
913
+ return {
914
+ provider: "spotify",
915
+ requested: resolutions.length,
916
+ resolved_count: result.resolved_count,
917
+ not_found_count: result.not_found_count,
918
+ resolutions,
919
+ expires_on: "prompt_end",
920
+ };
921
+ }
922
+
923
+ spotifyPlaylistPreviewMetadata(value, fallback = {}) {
924
+ const title = value?.title ?? value?.name ?? fallback.title;
925
+ const artists = Array.isArray(value?.artists)
926
+ ? value.artists
927
+ : typeof value?.artist_credit === "string"
928
+ ? [value.artist_credit]
929
+ : Array.isArray(fallback.artists)
930
+ ? fallback.artists
931
+ : [];
932
+ if (
933
+ typeof title !== "string" ||
934
+ !title ||
935
+ artists.length < 1 ||
936
+ artists.some((artist) => typeof artist !== "string" || !artist)
937
+ ) {
938
+ throw spotifyResolutionError(
939
+ "spotify_playlist_track_metadata_unavailable",
940
+ "A resolved Spotify track is missing safe display metadata, so the edit cannot be previewed.",
941
+ );
942
+ }
943
+ const album = value?.album ?? value?.release ?? fallback.album;
944
+ const durationMs = value?.duration_ms ?? fallback.duration_ms;
945
+ return {
946
+ title,
947
+ artists: [...artists],
948
+ ...(typeof album === "string" && album ? { album } : {}),
949
+ ...(Number.isInteger(durationMs) && durationMs >= 0
950
+ ? { duration_ms: durationMs }
951
+ : {}),
952
+ };
953
+ }
954
+
955
+ async spotifyPreviewPlaylistEdit({ playlistRefId, intent, items } = {}) {
956
+ this.requireSpotifyScopes(["user-read-private", "playlist-read-private"]);
957
+ this.requireSpotifyPlaylistTarget(playlistRefId);
958
+ const snapshot = this.spotifyPlaylistSnapshots.get(playlistRefId);
959
+ if (!snapshot) {
960
+ throw spotifyResolutionError(
961
+ "spotify_playlist_not_inspected",
962
+ "Inspect the Spotify playlist in this prompt before previewing an edit.",
963
+ );
964
+ }
965
+ if (
966
+ typeof intent !== "string" ||
967
+ !intent.trim() ||
968
+ Array.from(intent).length > 500
969
+ ) {
970
+ throw spotifyResolutionError(
971
+ "invalid_spotify_playlist_edit_intent",
972
+ "The Spotify playlist edit intent is invalid.",
973
+ );
974
+ }
975
+ if (!Array.isArray(items) || items.length < 1 || items.length > 100) {
976
+ throw spotifyResolutionError(
977
+ "invalid_spotify_playlist_edit_items",
978
+ "A Spotify playlist edit must contain 1 to 100 final tracks.",
979
+ );
980
+ }
981
+
982
+ const existingRefs = new Set();
983
+ const finalItems = [];
984
+ for (const item of items) {
985
+ const hasExisting =
986
+ item &&
987
+ typeof item === "object" &&
988
+ !Array.isArray(item) &&
989
+ typeof item.playlistItemRefId === "string";
990
+ const hasAdded =
991
+ item &&
992
+ typeof item === "object" &&
993
+ !Array.isArray(item) &&
994
+ typeof item.trackRefId === "string";
995
+ if (hasExisting === hasAdded) {
996
+ throw spotifyResolutionError(
997
+ "invalid_spotify_playlist_edit_items",
998
+ "Each final playlist position must reference either one inspected item or one resolved track.",
999
+ );
1000
+ }
1001
+ if (hasExisting) {
1002
+ const retained = this.spotifyPlaylistItems.get(item.playlistItemRefId);
1003
+ if (
1004
+ !retained ||
1005
+ retained.playlistRefId !== playlistRefId ||
1006
+ existingRefs.has(item.playlistItemRefId)
1007
+ ) {
1008
+ throw spotifyResolutionError(
1009
+ "invalid_spotify_playlist_item_ref",
1010
+ "An inspected Spotify playlist item reference is invalid or repeated.",
1011
+ );
1012
+ }
1013
+ existingRefs.add(item.playlistItemRefId);
1014
+ finalItems.push({
1015
+ uri: retained.uri,
1016
+ source: "existing",
1017
+ playlistItemRefId: item.playlistItemRefId,
1018
+ originalPosition: retained.position,
1019
+ metadata: this.spotifyPlaylistPreviewMetadata(retained.metadata),
1020
+ });
1021
+ continue;
1022
+ }
1023
+
1024
+ const resolution = this.requireSpotifyResolution(item.trackRefId);
1025
+ let trustedTrack = null;
1026
+ if (!resolution.matched && this.playlistServicesReady()) {
1027
+ [trustedTrack] = this.requirePlaylistServices().getTrustedTracks([
1028
+ item.trackRefId,
1029
+ ]);
1030
+ }
1031
+ finalItems.push({
1032
+ uri: resolution.uri,
1033
+ source: "added",
1034
+ trackRefId: item.trackRefId,
1035
+ metadata: this.spotifyPlaylistPreviewMetadata(
1036
+ resolution.matched,
1037
+ trustedTrack ?? {},
1038
+ ),
1039
+ });
1040
+ }
1041
+
1042
+ const oldUris = snapshot.items.map((item) => item.uri);
1043
+ const finalUris = finalItems.map((item) => item.uri);
1044
+ if (
1045
+ oldUris.length === finalUris.length &&
1046
+ oldUris.every((uri, index) => uri === finalUris[index])
1047
+ ) {
1048
+ throw spotifyResolutionError(
1049
+ "spotify_playlist_edit_unchanged",
1050
+ "The preview is identical to the current Spotify playlist.",
1051
+ );
1052
+ }
1053
+
1054
+ const removed = snapshot.items.filter(
1055
+ (item) => !existingRefs.has(item.playlistItemRefId),
1056
+ );
1057
+ const projectedItems = finalItems.map((item, index) => ({
1058
+ position: index + 1,
1059
+ status:
1060
+ item.source === "added"
1061
+ ? "added"
1062
+ : item.originalPosition === index + 1
1063
+ ? "retained"
1064
+ : "moved",
1065
+ ...structuredClone(item.metadata),
1066
+ }));
1067
+ const preview = {
1068
+ provider: "spotify",
1069
+ action: "playlist.edit",
1070
+ state: "preview",
1071
+ intent: intent.replace(/\s+/gu, " ").trim(),
1072
+ playlist: {
1073
+ playlist_ref_id: playlistRefId,
1074
+ name: snapshot.name,
1075
+ before_track_count: snapshot.items.length,
1076
+ after_track_count: finalItems.length,
1077
+ is_public: false,
1078
+ },
1079
+ changes: {
1080
+ added: finalItems.filter((item) => item.source === "added").length,
1081
+ removed: removed.length,
1082
+ moved: projectedItems.filter((item) => item.status === "moved").length,
1083
+ retained: projectedItems.filter((item) => item.status === "retained").length,
1084
+ },
1085
+ items: projectedItems,
1086
+ removed_items: removed.map((item) => ({
1087
+ previous_position: item.position,
1088
+ ...this.spotifyPlaylistPreviewMetadata(item.metadata),
1089
+ })),
1090
+ requires_confirmation: true,
1091
+ confirmation_timing: "next_prompt",
1092
+ external_effects: "none",
1093
+ };
1094
+ this.pendingSpotifyPlaylistEdit = {
1095
+ confirmable: false,
1096
+ playlistId: snapshot.playlistId,
1097
+ expectedSnapshotId: snapshot.snapshotId,
1098
+ uris: finalUris,
1099
+ preview: structuredClone(preview),
1100
+ };
1101
+ this.pendingSpotifyPlaylist = null;
1102
+ this.pendingPlaylistRevisionCandidateSet = null;
1103
+ return preview;
1104
+ }
1105
+
1106
+ pendingSpotifyPlaylistEditStatus() {
1107
+ const draft = this.pendingSpotifyPlaylistEdit;
1108
+ if (!draft) return { state: "none" };
1109
+ return {
1110
+ state: "available",
1111
+ confirmable: draft.confirmable === true,
1112
+ lifetime: "current_process_until_replaced_written_or_stale",
1113
+ exact_draft_retained_by_host: true,
1114
+ preview: {
1115
+ action: draft.preview.action,
1116
+ intent: draft.preview.intent,
1117
+ playlist: structuredClone(draft.preview.playlist),
1118
+ changes: structuredClone(draft.preview.changes),
1119
+ },
1120
+ };
1121
+ }
1122
+
1123
+ async spotifyApplyPendingPlaylistEdit() {
1124
+ const draft = this.pendingSpotifyPlaylistEdit;
1125
+ if (!draft) {
1126
+ throw spotifyResolutionError(
1127
+ "spotify_pending_playlist_edit_unavailable",
1128
+ "There is no pending Spotify playlist edit to apply. Inspect and preview an edit first.",
1129
+ );
1130
+ }
1131
+ if (draft.confirmable !== true) {
1132
+ throw spotifyResolutionError(
1133
+ "spotify_playlist_edit_confirmation_required",
1134
+ "A Spotify playlist edit can be applied only after the preview completes and the user confirms it in a later prompt.",
1135
+ );
1136
+ }
1137
+ this.requireSpotifyScopes(["user-read-private", "playlist-read-private"]);
1138
+ this.requireSpotifyWriteScopes(["playlist-modify-private"]);
1139
+ try {
1140
+ const receipt = await this.requireSpotifyService().replacePlaylistItems({
1141
+ playlistId: draft.playlistId,
1142
+ expectedSnapshotId: draft.expectedSnapshotId,
1143
+ uris: draft.uris,
1144
+ });
1145
+ if (this.pendingPlaylistPromptTransaction) {
1146
+ this.pendingPlaylistPromptTransaction.externalized = true;
1147
+ }
1148
+ this.pendingSpotifyPlaylistEdit = null;
1149
+ return receipt;
1150
+ } catch (error) {
1151
+ if (error?.code === "playlist_snapshot_changed") {
1152
+ this.pendingSpotifyPlaylistEdit = null;
1153
+ if (this.pendingPlaylistPromptTransaction) {
1154
+ this.pendingPlaylistPromptTransaction.editInvalidated = true;
1155
+ }
1156
+ }
1157
+ throw error;
1158
+ }
1159
+ }
1160
+
1161
+ async spotifyCreatePlaylist({ name, description, trackRefs } = {}) {
1162
+ if (
1163
+ !Array.isArray(trackRefs) ||
1164
+ !Array.isArray(this.validatedPlaylistTrackRefs) ||
1165
+ trackRefs.length !== this.validatedPlaylistTrackRefs.length ||
1166
+ trackRefs.some(
1167
+ (trackRefId, index) =>
1168
+ trackRefId !== this.validatedPlaylistTrackRefs[index],
1169
+ )
1170
+ ) {
1171
+ throw spotifyResolutionError(
1172
+ "spotify_playlist_plan_required",
1173
+ "Spotify playlist writes require the exact validated playlist plan from this prompt.",
1174
+ );
1175
+ }
1176
+ const details = spotifyPlaylistDetails({ name, description });
1177
+ this.requireSpotifyWriteScopes(["playlist-modify-private"]);
1178
+ const uris = this.requireSpotifyTrackUris(trackRefs);
1179
+ return this.writeSpotifyPlaylist(details, uris);
1180
+ }
1181
+
1182
+ pendingSpotifyPlaylistStatus() {
1183
+ const plan = this.pendingSpotifyPlaylist?.plan;
1184
+ if (!plan) return { state: "none" };
1185
+ const candidateSet = this.pendingPlaylistRevisionCandidateSet;
1186
+ return {
1187
+ state: "available",
1188
+ track_count: plan.track_count,
1189
+ intent: plan.intent,
1190
+ lifetime: "current_process_until_replaced_or_written",
1191
+ revision: candidateSet
1192
+ ? {
1193
+ state: "ready",
1194
+ candidate_set_id: candidateSet.candidate_set_id,
1195
+ candidate_scope: candidateSet.candidate_scope,
1196
+ expires_on: candidateSet.expires_on,
1197
+ allowed_operations: ["reorder", "remove", "replace", "add"],
1198
+ requires_playlist_plan_validation: true,
1199
+ external_effects: "none_until_explicit_write",
1200
+ ordering_rationale: plan.ordering_rationale,
1201
+ tracks: structuredClone(plan.tracks),
1202
+ discovery_sources: this.pendingPlaylistDiscoverySources(),
1203
+ }
1204
+ : {
1205
+ state: "available_on_next_prompt",
1206
+ requires_playlist_plan_validation: true,
1207
+ },
1208
+ };
1209
+ }
1210
+
1211
+ pendingSpotifyPlaylistPlan() {
1212
+ if (!this.pendingSpotifyPlaylist?.plan) {
1213
+ throw spotifyResolutionError(
1214
+ "spotify_pending_playlist_unavailable",
1215
+ "There is no pending validated playlist plan to save. Create a playlist plan first.",
1216
+ );
1217
+ }
1218
+ return structuredClone(this.pendingSpotifyPlaylist.plan);
1219
+ }
1220
+
1221
+ async spotifyCreatePendingPlaylist({ name, description } = {}) {
1222
+ if (!this.pendingSpotifyPlaylist) {
1223
+ throw spotifyResolutionError(
1224
+ "spotify_pending_playlist_unavailable",
1225
+ "There is no pending validated playlist plan to save. Create a playlist plan first.",
1226
+ );
1227
+ }
1228
+ const details = spotifyPlaylistDetails({ name, description });
1229
+ this.requireSpotifyScopes(["user-read-private"]);
1230
+ this.requireSpotifyWriteScopes(["playlist-modify-private"]);
1231
+ const draft = this.pendingSpotifyPlaylist;
1232
+ const result = await this.requireSpotifyResolver().resolve(
1233
+ draft.trustedTracks,
1234
+ );
1235
+ const resolvedByRef = new Map();
1236
+ for (const resolution of result.resolutions ?? []) {
1237
+ if (resolution.status === "resolved" && resolution.spotify?.uri) {
1238
+ resolvedByRef.set(
1239
+ resolution.track_ref_id,
1240
+ resolution.spotify.uri,
1241
+ );
1242
+ }
1243
+ }
1244
+ const uris = draft.plan.tracks.map((track) =>
1245
+ resolvedByRef.get(track.track_ref_id),
1246
+ );
1247
+ if (uris.some((uri) => typeof uri !== "string")) {
1248
+ throw spotifyResolutionError(
1249
+ "spotify_pending_playlist_unresolved",
1250
+ "The pending playlist could not be fully resolved on Spotify, so no playlist was created.",
1251
+ );
1252
+ }
1253
+ return this.writeSpotifyPlaylist(details, uris);
1254
+ }
1255
+
1256
+ async writeSpotifyPlaylist(details, uris) {
1257
+ try {
1258
+ const receipt = await this.requireSpotifyService().createPlaylistWithTracks({
1259
+ ...details,
1260
+ uris,
1261
+ });
1262
+ if (this.pendingPlaylistPromptTransaction) {
1263
+ this.pendingPlaylistPromptTransaction.externalized = true;
1264
+ }
1265
+ this.pendingSpotifyPlaylist = null;
1266
+ this.pendingPlaylistRevisionCandidateSet = null;
1267
+ return receipt;
1268
+ } catch (error) {
1269
+ if (error?.code === "playlist_created_without_tracks") {
1270
+ if (this.pendingPlaylistPromptTransaction) {
1271
+ this.pendingPlaylistPromptTransaction.externalized = true;
1272
+ }
1273
+ this.pendingSpotifyPlaylist = null;
1274
+ this.pendingPlaylistRevisionCandidateSet = null;
1275
+ }
1276
+ throw error;
1277
+ }
1278
+ }
1279
+
1280
+ async spotifySaveLibraryTracks({ trackRefs } = {}) {
1281
+ this.requireSpotifyWriteScopes(["user-library-modify"]);
1282
+ const uris = this.requireSpotifyTrackUris(trackRefs);
1283
+ return this.requireSpotifyService().saveTracks({ uris });
1284
+ }
1285
+
1286
+ async spotifyCheckLibraryTracks({ trackRefs } = {}) {
1287
+ this.requireSpotifyScopes(["user-library-read"]);
1288
+ const uris = this.requireSpotifyTrackUris(trackRefs);
1289
+ const result = await this.requireSpotifyService().checkSavedTracks({
1290
+ uris,
1291
+ });
1292
+ return {
1293
+ provider: "spotify",
1294
+ checked: result.checked.map((entry, index) => ({
1295
+ track_ref_id: trackRefs[index],
1296
+ saved: entry.saved,
1297
+ })),
1298
+ };
1299
+ }
1300
+
1301
+ webResearchReady() {
1302
+ return this.webResearch?.publicStatus().state === "configured";
1303
+ }
1304
+
1305
+ async searchWeb(input, options) {
1306
+ if (!this.webResearchReady()) throw new Error("Codex web search is unavailable. Run /web status for setup details.");
1307
+ return this.webResearch.search(input, options);
1308
+ }
1309
+
1310
+ async readWeb(input, options) {
1311
+ if (!this.webResearchReady()) throw new Error("Codex web reading is unavailable. Run /web status for setup details.");
1312
+ return this.webResearch.read(input, options);
1313
+ }
1314
+
1315
+ toolsStatus() {
1316
+ return {
1317
+ registry_version:
1318
+ "a3-s3-open-similarity+a4-s4+rediscovery+historical-returns+time-capsule+back-to-back/1",
1319
+ capabilities: listCapabilities({
1320
+ domainServicesReady: this.domainServicesReady(),
1321
+ profileServicesReady: this.profileServicesReady(),
1322
+ playlistServicesReady: this.playlistServicesReady(),
1323
+ rediscoveryReady: this.rediscoveryServicesReady(),
1324
+ historicalReturnReady: this.historicalReturnServicesReady(),
1325
+ timeCapsuleReady: this.timeCapsuleServicesReady(),
1326
+ backToBackReady: this.backToBackServicesReady(),
1327
+ memoryReady: this.memoryStore !== null,
1328
+ spotifyReady: this.spotifyReady(),
1329
+ musicCatalogReady: this.musicCatalogReady(),
1330
+ musicDiscoveryReady: this.musicDiscoveryReady(),
1331
+ musicSimilarityReady: this.musicSimilarityReady(),
1332
+ webResearchReady: this.webResearchReady(),
1333
+ }),
1334
+ };
1335
+ }
1336
+
1337
+ agentCapabilityDescriptors() {
1338
+ return listAgentCapabilityDescriptors({
1339
+ domainServicesReady: this.domainServicesReady(),
1340
+ profileServicesReady: this.profileServicesReady(),
1341
+ playlistServicesReady: this.playlistServicesReady(),
1342
+ rediscoveryReady: this.rediscoveryServicesReady(),
1343
+ historicalReturnReady: this.historicalReturnServicesReady(),
1344
+ timeCapsuleReady: this.timeCapsuleServicesReady(),
1345
+ backToBackReady: this.backToBackServicesReady(),
1346
+ memoryReady: this.memoryStore !== null,
1347
+ spotifyReady: this.spotifyReady(),
1348
+ musicCatalogReady: this.musicCatalogReady(),
1349
+ musicDiscoveryReady: this.musicDiscoveryReady(),
1350
+ musicSimilarityReady: this.musicSimilarityReady(),
1351
+ webResearchReady: this.webResearchReady(),
1352
+ });
1353
+ }
1354
+
1355
+ async findArtistReleases(input) {
1356
+ const request = { ...input };
1357
+ if (
1358
+ request.knownRelease === undefined &&
1359
+ this.domainServicesReady()
1360
+ ) {
1361
+ try {
1362
+ const localMatches = await this.requireDomainServices().searchLibrary({
1363
+ query: request.artistName,
1364
+ limit: 12,
1365
+ offset: 0,
1366
+ filters: {
1367
+ artists: [request.artistName],
1368
+ },
1369
+ });
1370
+ const knownRelease = exactArtistReleaseHint(
1371
+ localMatches,
1372
+ request.artistName,
1373
+ );
1374
+ if (knownRelease) request.knownRelease = knownRelease;
1375
+ } catch {
1376
+ // Catalog lookup remains useful when the optional local hint is unavailable.
1377
+ }
1378
+ }
1379
+ const catalog = this.requireMusicCatalog();
1380
+ const result = await catalog.findArtistReleases(request);
1381
+ return recoverArtistReleasesWithCrossCatalogIdentity({
1382
+ artistName: request.artistName,
1383
+ currentResult: result,
1384
+ catalog,
1385
+ identityResolver: this.artistIdentityResolver,
1386
+ });
1387
+ }
1388
+
1389
+ recordPromptDiscoverySource(source) {
1390
+ if (!source || typeof source !== "object" || Array.isArray(source)) return;
1391
+ if (
1392
+ this.promptDiscoverySources.some(
1393
+ (entry) =>
1394
+ entry.provider === source.provider &&
1395
+ entry.retrieved_at === source.retrieved_at,
1396
+ )
1397
+ ) {
1398
+ return;
1399
+ }
1400
+ this.promptDiscoverySources.push(structuredClone(source));
1401
+ }
1402
+
1403
+ pendingPlaylistDiscoverySources() {
1404
+ return structuredClone(this.pendingSpotifyPlaylist?.discoverySources ?? []);
1405
+ }
1406
+
1407
+ async searchMusicCatalog(input) {
1408
+ const { catalog, domainServices } = this.requireMusicDiscovery();
1409
+ const result = await catalog.searchTracks(input);
1410
+ if (result.state !== "resolved" || result.tracks.length === 0) {
1411
+ return {
1412
+ ...result,
1413
+ candidate_set_id: null,
1414
+ candidate_scope: "external_catalog",
1415
+ excluded_library_matches: 0,
1416
+ expires_on: "prompt_end",
1417
+ };
1418
+ }
1419
+ const registered = domainServices.registerExternalCandidateSet({
1420
+ tracks: result.tracks,
1421
+ source: result.source,
1422
+ });
1423
+ if (registered.result_count > 0) {
1424
+ this.recordPromptDiscoverySource(registered.source);
1425
+ }
1426
+ return {
1427
+ ...result,
1428
+ ...registered,
1429
+ state:
1430
+ registered.result_count > 0
1431
+ ? "resolved"
1432
+ : "not_found_after_library_filter",
1433
+ };
1434
+ }
1435
+
1436
+ async discoverSimilarMusic({ seedTrackRefId, mode, limit } = {}) {
1437
+ const { similarity, domainServices } = this.requireMusicSimilarity();
1438
+ const seedTrack = this.promptProfileSeed?.track_ref_id === seedTrackRefId
1439
+ ? this.promptProfileSeed
1440
+ : domainServices.getTrustedTracks([seedTrackRefId])[0];
1441
+ const result = await similarity.discoverSimilarTracks({
1442
+ artistName: seedTrack.artist_credit,
1443
+ mode,
1444
+ limit,
1445
+ });
1446
+ const seed = {
1447
+ track_ref_id: seedTrack.track_ref_id,
1448
+ title: seedTrack.title,
1449
+ artist_credit: seedTrack.artist_credit,
1450
+ release: seedTrack.release,
1451
+ ...result.seed,
1452
+ };
1453
+ if (result.state !== "resolved" || result.tracks.length === 0) {
1454
+ return {
1455
+ ...result,
1456
+ seed,
1457
+ candidate_set_id: null,
1458
+ candidate_scope: "external_catalog",
1459
+ excluded_library_matches: 0,
1460
+ expires_on: "prompt_end",
1461
+ };
1462
+ }
1463
+ const registered = domainServices.registerExternalCandidateSet({
1464
+ tracks: result.tracks,
1465
+ source: result.source,
1466
+ });
1467
+ if (registered.result_count > 0) {
1468
+ this.recordPromptDiscoverySource(registered.source);
1469
+ }
1470
+ return {
1471
+ ...result,
1472
+ ...registered,
1473
+ seed,
1474
+ state:
1475
+ registered.result_count > 0
1476
+ ? "resolved"
1477
+ : "not_found_after_library_filter",
1478
+ };
1479
+ }
1480
+
1481
+ async searchLibrary(input) {
1482
+ return this.requireDomainServices().searchLibrary(input);
1483
+ }
1484
+
1485
+ async getProfileSummary(input) {
1486
+ return this.requireProfileServices().getProfileSummary(input);
1487
+ }
1488
+
1489
+ // Host-only personalization; lyric text is not injected into model context.
1490
+ async getLyricSeeds() {
1491
+ if (!this.profileServicesReady()) return { subjectId: null, tracks: [] };
1492
+ return this.requireProfileServices().getLyricSeeds?.() ?? { subjectId: null, tracks: [] };
1493
+ }
1494
+
1495
+ // Host-only handoff from a track selected in the local profile, never a model tool.
1496
+ setProfileDiscoverySeed(selection) {
1497
+ if (!this.pendingPlaylistPromptTransaction) {
1498
+ throw new Error("A selected profile track requires an active prompt.");
1499
+ }
1500
+ const clean = (value) => typeof value === "string"
1501
+ ? value.replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/gu, " ")
1502
+ .replace(/\s+/gu, " ").trim()
1503
+ : "";
1504
+ const title = clean(selection?.label);
1505
+ const artist = clean(selection?.artistCredit);
1506
+ if (selection?.entityType !== "track" || !title || !artist ||
1507
+ Array.from(title).length > 512 || Array.from(artist).length > 512) {
1508
+ throw new TypeError("The selected profile track is invalid.");
1509
+ }
1510
+ this.promptProfileSeed = {
1511
+ track_ref_id: randomUUID(),
1512
+ title,
1513
+ artist_credit: artist,
1514
+ source: "user_selected_profile_track",
1515
+ expires_on: "prompt_end",
1516
+ };
1517
+ }
1518
+
1519
+ profileDiscoverySeedContext() {
1520
+ return structuredClone(this.promptProfileSeed);
1521
+ }
1522
+
1523
+ async getRediscoveryCandidates(input) {
1524
+ return this.requireRediscoveryServices().getRediscoveryCandidates(input);
1525
+ }
1526
+
1527
+ async getHistoricalReturnCandidates(input) {
1528
+ return this.requireHistoricalReturnServices()
1529
+ .getHistoricalReturnCandidates(input);
1530
+ }
1531
+
1532
+ async getTimeCapsuleCandidates(input) {
1533
+ return this.requireTimeCapsuleServices().getTimeCapsuleCandidates(input);
1534
+ }
1535
+
1536
+ async getBackToBackCandidates(input) {
1537
+ return this.requireBackToBackServices().getBackToBackCandidates(input);
1538
+ }
1539
+
1540
+ async explainProfileEvidence(input) {
1541
+ return this.requireProfileServices().explainProfileEvidence(input);
1542
+ }
1543
+
1544
+ async buildPlaylistPlan(input) {
1545
+ const plan = await this.requirePlaylistServices().buildPlaylistPlan(input);
1546
+ const validatedPlaylistTrackRefs = Array.isArray(plan?.tracks)
1547
+ ? plan.tracks.map((track) => track.track_ref_id)
1548
+ : null;
1549
+ const trustedTracks = validatedPlaylistTrackRefs
1550
+ ? this.requirePlaylistServices().getTrustedTracks(
1551
+ validatedPlaylistTrackRefs,
1552
+ )
1553
+ : null;
1554
+ if (trustedTracks) {
1555
+ const byRef = new Map(trustedTracks.map((track) => [track.track_ref_id, track]));
1556
+ for (const track of plan.tracks) {
1557
+ // Presentation evidence comes from registered candidates, never model prose.
1558
+ delete track.discovery_evidence;
1559
+ const trusted = byRef.get(track.track_ref_id);
1560
+ if (track.candidate_scope !== "external_catalog" || trusted?.candidate_scope !== "external_catalog") continue;
1561
+ if (trusted.catalog_provider === "listenbrainz" && trusted.discovery_basis) {
1562
+ track.discovery_evidence = {
1563
+ provider: "listenbrainz",
1564
+ seed_artist: trusted.discovery_basis.seed_artist,
1565
+ adjacent_artist: trusted.discovery_basis.adjacent_artist,
1566
+ };
1567
+ } else if (trusted.catalog_provider === "apple_music") {
1568
+ track.discovery_evidence = {
1569
+ provider: "apple_music",
1570
+ matched_queries: [...(trusted.matched_queries ?? [])],
1571
+ ...(trusted.primary_genre ? { primary_genre: trusted.primary_genre } : {}),
1572
+ };
1573
+ }
1574
+ }
1575
+ }
1576
+ this.validatedPlaylistTrackRefs = validatedPlaylistTrackRefs;
1577
+ this.pendingSpotifyPlaylist = validatedPlaylistTrackRefs
1578
+ ? {
1579
+ plan: structuredClone(plan),
1580
+ trustedTracks,
1581
+ discoverySources: structuredClone(this.promptDiscoverySources),
1582
+ }
1583
+ : null;
1584
+ this.pendingSpotifyPlaylistEdit = null;
1585
+ this.pendingPlaylistRevisionCandidateSet = null;
1586
+ return plan;
1587
+ }
1588
+
1589
+ commitPendingPlaylistPrompt() {
1590
+ const active = this.pendingPlaylistPromptTransaction !== null;
1591
+ if (this.pendingSpotifyPlaylistEdit?.confirmable === false) {
1592
+ this.pendingSpotifyPlaylistEdit.confirmable = true;
1593
+ }
1594
+ this.pendingPlaylistPromptTransaction = null;
1595
+ return { committed_pending_playlist_prompt: active };
1596
+ }
1597
+
1598
+ rollbackPendingPlaylistPrompt() {
1599
+ const transaction = this.pendingPlaylistPromptTransaction;
1600
+ if (!transaction) {
1601
+ return { rolled_back_pending_playlist_prompt: false };
1602
+ }
1603
+ if (!transaction.externalized) {
1604
+ this.pendingSpotifyPlaylist = structuredClone(
1605
+ transaction.pendingSpotifyPlaylist,
1606
+ );
1607
+ this.pendingSpotifyPlaylistEdit = transaction.editInvalidated
1608
+ ? null
1609
+ : structuredClone(transaction.pendingSpotifyPlaylistEdit);
1610
+ }
1611
+ this.pendingPlaylistPromptTransaction = null;
1612
+ return {
1613
+ rolled_back_pending_playlist_prompt: !transaction.externalized,
1614
+ external_effect_preserved: transaction.externalized,
1615
+ };
1616
+ }
1617
+
1618
+ beginPrompt() {
1619
+ if (this.pendingPlaylistPromptTransaction) {
1620
+ throw new Error("A Moondog prompt state transaction is already active.");
1621
+ }
1622
+ this.pendingPlaylistPromptTransaction = {
1623
+ pendingSpotifyPlaylist: structuredClone(this.pendingSpotifyPlaylist),
1624
+ pendingSpotifyPlaylistEdit: structuredClone(
1625
+ this.pendingSpotifyPlaylistEdit,
1626
+ ),
1627
+ externalized: false,
1628
+ editInvalidated: false,
1629
+ };
1630
+ this.resetSpotifyResolutions();
1631
+ this.resetSpotifyPlaylistInspection();
1632
+ this.promptProfileSeed = null;
1633
+ this.pendingPlaylistRevisionCandidateSet = null;
1634
+ this.promptDiscoverySources = structuredClone(
1635
+ this.pendingSpotifyPlaylist?.discoverySources ?? [],
1636
+ );
1637
+ if (!this.playlistServicesReady()) return undefined;
1638
+ try {
1639
+ const result = this.domainServices.beginPrompt();
1640
+ if (!this.pendingSpotifyPlaylist?.trustedTracks) return result;
1641
+ this.pendingPlaylistRevisionCandidateSet =
1642
+ this.domainServices.registerRetainedPlaylistCandidateSet({
1643
+ tracks: this.pendingSpotifyPlaylist.trustedTracks,
1644
+ });
1645
+ return result;
1646
+ } catch (error) {
1647
+ this.domainServices.resetCandidateSets();
1648
+ this.pendingPlaylistRevisionCandidateSet = null;
1649
+ this.promptDiscoverySources = [];
1650
+ this.pendingPlaylistPromptTransaction = null;
1651
+ throw error;
1652
+ }
1653
+ }
1654
+
1655
+ endPrompt() {
1656
+ this.resetSpotifyResolutions();
1657
+ this.resetSpotifyPlaylistInspection();
1658
+ try {
1659
+ const result = this.playlistServicesReady()
1660
+ ? this.domainServices.endPrompt()
1661
+ : undefined;
1662
+ this.commitPendingPlaylistPrompt();
1663
+ return result;
1664
+ } catch (error) {
1665
+ this.rollbackPendingPlaylistPrompt();
1666
+ throw error;
1667
+ } finally {
1668
+ this.pendingPlaylistRevisionCandidateSet = null;
1669
+ this.promptDiscoverySources = [];
1670
+ this.promptProfileSeed = null;
1671
+ }
1672
+ }
1673
+
1674
+ resetPromptState() {
1675
+ this.resetSpotifyResolutions();
1676
+ this.resetSpotifyPlaylistInspection();
1677
+ try {
1678
+ if (!this.playlistServicesReady()) return undefined;
1679
+ return this.domainServices.resetCandidateSets();
1680
+ } finally {
1681
+ this.rollbackPendingPlaylistPrompt();
1682
+ this.pendingPlaylistRevisionCandidateSet = null;
1683
+ this.promptDiscoverySources = [];
1684
+ this.promptProfileSeed = null;
1685
+ }
1686
+ }
1687
+
1688
+ close() {
1689
+ try {
1690
+ this.memorySession = null;
1691
+ this.pendingSpotifyPlaylist = null;
1692
+ this.pendingSpotifyPlaylistEdit = null;
1693
+ this.pendingPlaylistRevisionCandidateSet = null;
1694
+ this.resetSpotifyPlaylistInspection();
1695
+ this.promptDiscoverySources = [];
1696
+ this.promptProfileSeed = null;
1697
+ this.pendingPlaylistPromptTransaction = null;
1698
+ } finally {
1699
+ this.memoryStore?.close?.();
1700
+ this.domainServices?.close?.();
1701
+ this.musicCatalog?.close?.();
1702
+ this.musicSimilarity?.close?.();
1703
+ this.webResearch?.close?.();
1704
+ }
1705
+ }
1706
+
1707
+ async status(runtime = { state: "offline" }) {
1708
+ return {
1709
+ product: "moondog",
1710
+ milestone: "A3-S3+A4-S4",
1711
+ client: "cli_tui",
1712
+ runtime,
1713
+ source: await this.sourceStatus(),
1714
+ profile: await this.profileStatus(),
1715
+ memory: this.memoryStatus(),
1716
+ web_research: this.webResearch?.publicStatus?.() ?? { provider: "codex_cli", state: "unavailable" },
1717
+ music_catalog: {
1718
+ provider: "apple_music",
1719
+ state: this.musicCatalogReady() ? "configured" : "unavailable",
1720
+ access: "read_only",
1721
+ external_candidate_planning: this.musicDiscoveryReady(),
1722
+ },
1723
+ music_similarity: {
1724
+ provider: "listenbrainz",
1725
+ identity_provider: "wikidata",
1726
+ state: this.musicSimilarityReady() ? "configured" : "unavailable",
1727
+ access: "read_only",
1728
+ external_candidate_planning: this.musicSimilarityReady(),
1729
+ },
1730
+ spotify: this.spotifyStatus(),
1731
+ external_effects: this.spotifyReady() ? "spotify_control" : "disabled",
1732
+ };
1733
+ }
1734
+
1735
+ async doctor(runtime = { state: "offline" }) {
1736
+ const source = await this.sourceStatus();
1737
+ const profile = await this.profileStatus();
1738
+ const spotify = this.spotifyStatus();
1739
+ const checks = [
1740
+ {
1741
+ id: "web.codex_cli",
1742
+ ok: this.webResearchReady(),
1743
+ state: this.webResearchReady() ? "configured" : "unavailable",
1744
+ optional_for_local_commands: true,
1745
+ },
1746
+ {
1747
+ id: "node.version",
1748
+ ok: versionAtLeast(process.versions.node, minimumNodeVersion),
1749
+ actual: process.versions.node,
1750
+ required: ">=22.19.0",
1751
+ },
1752
+ {
1753
+ id: "apple_music.source",
1754
+ ok: ["ready", "degraded"].includes(source.state),
1755
+ state: source.state,
1756
+ optional_for_local_commands: profile.state === "ready",
1757
+ },
1758
+ {
1759
+ id: "profile.projection",
1760
+ ok: profile.state === "ready",
1761
+ state: profile.state,
1762
+ ...(profile.error ? { error: profile.error } : {}),
1763
+ },
1764
+ {
1765
+ id: "agent.runtime",
1766
+ ok: runtime.state === "configured",
1767
+ state: runtime.state,
1768
+ optional_for_local_commands: true,
1769
+ },
1770
+ {
1771
+ id: "music.discovery.open_similarity",
1772
+ ok: this.musicSimilarityReady(),
1773
+ state: this.musicSimilarityReady() ? "configured" : "unavailable",
1774
+ },
1775
+ {
1776
+ id: "external.effects",
1777
+ ok: true,
1778
+ state: this.spotifyReady() ? "spotify_control" : "disabled",
1779
+ },
1780
+ {
1781
+ id: "spotify.connection",
1782
+ ok: this.spotifyReady(),
1783
+ state: spotify.state,
1784
+ optional_for_local_commands: true,
1785
+ },
1786
+ {
1787
+ id: "spotify.connected_action_scopes",
1788
+ ok: spotify.scopes?.sufficient === true,
1789
+ state:
1790
+ spotify.scopes?.sufficient === true
1791
+ ? "ready"
1792
+ : this.spotifyReady()
1793
+ ? "reauthentication_required"
1794
+ : "not_configured",
1795
+ missing_scopes: spotify.scopes?.missing ?? [],
1796
+ optional_for_local_commands: true,
1797
+ },
1798
+ ];
1799
+
1800
+ return {
1801
+ ok: checks
1802
+ .filter((check) => !check.optional_for_local_commands)
1803
+ .every((check) => check.ok),
1804
+ checks,
1805
+ };
1806
+ }
1807
+
1808
+ async runLocalCommand(command, runtime = { state: "offline" }) {
1809
+ switch (command) {
1810
+ case "status":
1811
+ case "sources":
1812
+ return this.status(runtime);
1813
+ case "profile":
1814
+ return this.profileStatus();
1815
+ case "taste":
1816
+ return this.getProfileSummary({ maxItems: 5 });
1817
+ case "memory":
1818
+ return this.memorySummary();
1819
+ case "tools":
1820
+ return this.toolsStatus();
1821
+ case "doctor":
1822
+ return this.doctor(runtime);
1823
+ default:
1824
+ throw new Error(`Unknown local command: ${command}`);
1825
+ }
1826
+ }
1827
+ }