@lobu/cli 6.0.0 → 6.1.1

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 (222) hide show
  1. package/README.md +20 -27
  2. package/dist/bundled-skills/lobu/SKILL.md +12 -12
  3. package/dist/commands/_lib/apply/apply-cmd.d.ts +2 -0
  4. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  5. package/dist/commands/_lib/apply/apply-cmd.js +26 -0
  6. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  7. package/dist/commands/_lib/apply/client.d.ts +1 -1
  8. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  9. package/dist/commands/_lib/apply/desired-state.js +6 -6
  10. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  11. package/dist/commands/agent.d.ts +7 -0
  12. package/dist/commands/agent.d.ts.map +1 -1
  13. package/dist/commands/agent.js +65 -1
  14. package/dist/commands/agent.js.map +1 -1
  15. package/dist/commands/chat.d.ts +12 -9
  16. package/dist/commands/chat.d.ts.map +1 -1
  17. package/dist/commands/chat.js +117 -56
  18. package/dist/commands/chat.js.map +1 -1
  19. package/dist/commands/dev.d.ts +15 -7
  20. package/dist/commands/dev.d.ts.map +1 -1
  21. package/dist/commands/dev.js +79 -44
  22. package/dist/commands/dev.js.map +1 -1
  23. package/dist/commands/doctor.d.ts +1 -0
  24. package/dist/commands/doctor.d.ts.map +1 -1
  25. package/dist/commands/doctor.js +136 -0
  26. package/dist/commands/doctor.js.map +1 -1
  27. package/dist/commands/eval.d.ts +8 -0
  28. package/dist/commands/eval.d.ts.map +1 -1
  29. package/dist/commands/eval.js +56 -1
  30. package/dist/commands/eval.js.map +1 -1
  31. package/dist/commands/init.d.ts +20 -5
  32. package/dist/commands/init.d.ts.map +1 -1
  33. package/dist/commands/init.js +332 -183
  34. package/dist/commands/init.js.map +1 -1
  35. package/dist/commands/link.d.ts +11 -0
  36. package/dist/commands/link.d.ts.map +1 -0
  37. package/dist/commands/link.js +28 -0
  38. package/dist/commands/link.js.map +1 -0
  39. package/dist/commands/login.d.ts.map +1 -1
  40. package/dist/commands/login.js +14 -2
  41. package/dist/commands/login.js.map +1 -1
  42. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  43. package/dist/commands/memory/_lib/browser-auth-cmd.js +4 -4
  44. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  45. package/dist/commands/memory/_lib/install-targets.d.ts.map +1 -1
  46. package/dist/commands/memory/_lib/install-targets.js +1 -5
  47. package/dist/commands/memory/_lib/install-targets.js.map +1 -1
  48. package/dist/commands/memory/_lib/mcp.d.ts +2 -2
  49. package/dist/commands/memory/_lib/mcp.d.ts.map +1 -1
  50. package/dist/commands/memory/_lib/mcp.js +24 -12
  51. package/dist/commands/memory/_lib/mcp.js.map +1 -1
  52. package/dist/commands/memory/_lib/openclaw-auth.d.ts +1 -0
  53. package/dist/commands/memory/_lib/openclaw-auth.d.ts.map +1 -1
  54. package/dist/commands/memory/_lib/openclaw-auth.js +14 -3
  55. package/dist/commands/memory/_lib/openclaw-auth.js.map +1 -1
  56. package/dist/commands/memory/_lib/openclaw-cmd.js +1 -1
  57. package/dist/commands/memory/_lib/openclaw-cmd.js.map +1 -1
  58. package/dist/commands/memory/_lib/schema.d.ts +2 -2
  59. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  60. package/dist/commands/memory/_lib/schema.js +3 -3
  61. package/dist/commands/memory/_lib/schema.js.map +1 -1
  62. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  63. package/dist/commands/memory/_lib/seed-cmd.js +5 -6
  64. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  65. package/dist/commands/memory/run.d.ts.map +1 -1
  66. package/dist/commands/memory/run.js +2 -2
  67. package/dist/commands/memory/run.js.map +1 -1
  68. package/dist/commands/platforms/platform-prompts.d.ts +0 -1
  69. package/dist/commands/platforms/platform-prompts.d.ts.map +1 -1
  70. package/dist/commands/platforms/platform-prompts.js +54 -8
  71. package/dist/commands/platforms/platform-prompts.js.map +1 -1
  72. package/dist/commands/telemetry.d.ts +10 -0
  73. package/dist/commands/telemetry.d.ts.map +1 -0
  74. package/dist/commands/telemetry.js +68 -0
  75. package/dist/commands/telemetry.js.map +1 -0
  76. package/dist/commands/whoami.d.ts.map +1 -1
  77. package/dist/commands/whoami.js +1 -1
  78. package/dist/commands/whoami.js.map +1 -1
  79. package/dist/connectors/README.md +534 -0
  80. package/dist/connectors/__tests__/browser-scraper-utils.test.ts +186 -0
  81. package/dist/connectors/browser-scraper-utils.ts +214 -0
  82. package/dist/connectors/capterra.ts +273 -0
  83. package/dist/connectors/g2.ts +286 -0
  84. package/dist/connectors/github.ts +1553 -0
  85. package/dist/connectors/glassdoor.ts +291 -0
  86. package/dist/connectors/gmaps.ts +197 -0
  87. package/dist/connectors/google_calendar.ts +631 -0
  88. package/dist/connectors/google_gmail.ts +751 -0
  89. package/dist/connectors/google_photos.ts +776 -0
  90. package/dist/connectors/google_play.ts +342 -0
  91. package/dist/connectors/hackernews.ts +471 -0
  92. package/dist/connectors/index.ts +23 -0
  93. package/dist/connectors/ios_appstore.ts +226 -0
  94. package/dist/connectors/linkedin.ts +471 -0
  95. package/dist/connectors/microsoft_outlook.ts +410 -0
  96. package/dist/connectors/producthunt.ts +471 -0
  97. package/dist/connectors/reddit.ts +600 -0
  98. package/dist/connectors/rss.ts +448 -0
  99. package/dist/connectors/spotify.ts +590 -0
  100. package/dist/connectors/trustpilot.ts +199 -0
  101. package/dist/connectors/website.ts +629 -0
  102. package/dist/connectors/whatsapp.ts +1073 -0
  103. package/dist/connectors/x.ts +526 -0
  104. package/dist/connectors/youtube.ts +666 -0
  105. package/dist/db/migrations/00000000000000_baseline.sql +4867 -0
  106. package/dist/db/migrations/20260405193000_add_mcp_sessions.sql +33 -0
  107. package/dist/db/migrations/20260408120000_remove_system_connectors.sql +48 -0
  108. package/dist/db/migrations/20260408120001_optional_compiled_code.sql +6 -0
  109. package/dist/db/migrations/20260409110000_add_active_watcher_run_index.sql +9 -0
  110. package/dist/db/migrations/20260409130000_connector_default_config.sql +5 -0
  111. package/dist/db/migrations/20260410120000_add_agent_secrets.sql +25 -0
  112. package/dist/db/migrations/20260413170000_add_watcher_group_id.sql +67 -0
  113. package/dist/db/migrations/20260416120000_add_entity_wa_jid_index.sql +14 -0
  114. package/dist/db/migrations/20260417100000_add_entity_identities.sql +77 -0
  115. package/dist/db/migrations/20260418100000_add_auth_runs.sql +83 -0
  116. package/dist/db/migrations/20260418110000_add_runs_created_by_user.sql +18 -0
  117. package/dist/db/migrations/20260419120000_add_event_identity_indexes.sql +56 -0
  118. package/dist/db/migrations/20260420120000_extend_reserved_org_slugs.sql +56 -0
  119. package/dist/db/migrations/20260424030000_add_watcher_run_correlation.sql +52 -0
  120. package/dist/db/migrations/20260424130000_relax_events_client_id_fk.sql +47 -0
  121. package/dist/db/migrations/20260425100000_normalize_watcher_feedback.sql +91 -0
  122. package/dist/db/migrations/20260425120000_add_run_diagnostics.sql +20 -0
  123. package/dist/db/migrations/20260425130000_add_repair_agent_plumbing.sql +46 -0
  124. package/dist/db/migrations/20260426120000_entities_entity_type_fk.sql +101 -0
  125. package/dist/db/migrations/20260426130000_db_integrity_cleanup.sql +104 -0
  126. package/dist/db/migrations/20260426130001_db_integrity_cleanup_concurrent.sql +187 -0
  127. package/dist/db/migrations/20260427133000_events_created_by_nullable.sql +74 -0
  128. package/dist/db/migrations/20260427140000_identity_engine_indexes.sql +140 -0
  129. package/dist/db/migrations/20260427150000_drop_events_source_id.sql +177 -0
  130. package/dist/db/migrations/20260427160000_drop_dead_schema.sql +76 -0
  131. package/dist/db/migrations/20260427170000_market_founder_to_member.sql +364 -0
  132. package/dist/db/migrations/20260428040000_cascade_events_watchers_org_fk.sql +66 -0
  133. package/dist/db/migrations/20260428050000_add_runs_approved_input.sql +9 -0
  134. package/dist/db/migrations/20260429010000_auth_profile_tenant_scoped_fk.sql +79 -0
  135. package/dist/db/migrations/20260429060000_extend_runs_for_lobu_queue.sql +108 -0
  136. package/dist/db/migrations/20260429120000_agent_changed_notify.sql +97 -0
  137. package/dist/db/migrations/20260429120100_user_auth_profiles_and_model_prefs.sql +36 -0
  138. package/dist/db/migrations/20260429120200_fix_notify_old_keys.sql +130 -0
  139. package/dist/db/migrations/20260429130000_oauth_states_cli_sessions_rate_limits.sql +83 -0
  140. package/dist/db/migrations/20260429140000_phase8_grants_chat_connections_mcp_sessions.sql +84 -0
  141. package/dist/db/migrations/20260429140100_runs_priority_expires_at_retry_delay.sql +44 -0
  142. package/dist/db/migrations/20260429180000_drop_invalidatable_cache_triggers.sql +25 -0
  143. package/dist/db/migrations/20260430005614_agents_apply_fields.sql +21 -0
  144. package/dist/db/migrations/20260430022231_fix_connection_config_encryption.sql +69 -0
  145. package/dist/db/migrations/20260430151215_add_task_run_type.sql +77 -0
  146. package/dist/db/migrations/20260501000000_drop_cli_sessions.sql +27 -0
  147. package/dist/db/migrations/20260501133000_lobu_memory_mcp_id.sql +117 -0
  148. package/dist/db/migrations/20260502000000_drop_chat_connections.sql +60 -0
  149. package/dist/db/migrations/20260503000000_agent_secrets_org_scope.sql +56 -0
  150. package/dist/db/migrations/20260504000000_flatten_agents_drop_sandbox_model.sql +48 -0
  151. package/dist/index.d.ts.map +1 -1
  152. package/dist/index.js +147 -23
  153. package/dist/index.js.map +1 -1
  154. package/dist/internal/api-client.d.ts +4 -8
  155. package/dist/internal/api-client.d.ts.map +1 -1
  156. package/dist/internal/api-client.js +1 -1
  157. package/dist/internal/api-client.js.map +1 -1
  158. package/dist/internal/context.js +2 -2
  159. package/dist/internal/context.js.map +1 -1
  160. package/dist/internal/credentials.d.ts.map +1 -1
  161. package/dist/internal/credentials.js +6 -1
  162. package/dist/internal/credentials.js.map +1 -1
  163. package/dist/internal/index.d.ts +2 -3
  164. package/dist/internal/index.d.ts.map +1 -1
  165. package/dist/internal/index.js +2 -2
  166. package/dist/internal/index.js.map +1 -1
  167. package/dist/internal/oauth.d.ts +7 -6
  168. package/dist/internal/oauth.d.ts.map +1 -1
  169. package/dist/internal/oauth.js +3 -3
  170. package/dist/internal/project-link.d.ts +10 -0
  171. package/dist/internal/project-link.d.ts.map +1 -0
  172. package/dist/internal/project-link.js +48 -0
  173. package/dist/internal/project-link.js.map +1 -0
  174. package/dist/providers.json +2 -2
  175. package/dist/server.bundle.mjs +3173 -4404
  176. package/dist/start-local.bundle.mjs +71481 -0
  177. package/dist/templates/README.md.tmpl +10 -11
  178. package/package.json +14 -12
  179. package/dist/__tests__/chat.integration.test.d.ts +0 -2
  180. package/dist/__tests__/chat.integration.test.d.ts.map +0 -1
  181. package/dist/__tests__/chat.integration.test.js +0 -337
  182. package/dist/__tests__/chat.integration.test.js.map +0 -1
  183. package/dist/__tests__/dev.test.d.ts +0 -2
  184. package/dist/__tests__/dev.test.d.ts.map +0 -1
  185. package/dist/__tests__/dev.test.js +0 -25
  186. package/dist/__tests__/dev.test.js.map +0 -1
  187. package/dist/__tests__/init-memory.test.d.ts +0 -2
  188. package/dist/__tests__/init-memory.test.d.ts.map +0 -1
  189. package/dist/__tests__/init-memory.test.js +0 -45
  190. package/dist/__tests__/init-memory.test.js.map +0 -1
  191. package/dist/__tests__/token.test.d.ts +0 -2
  192. package/dist/__tests__/token.test.d.ts.map +0 -1
  193. package/dist/__tests__/token.test.js +0 -52
  194. package/dist/__tests__/token.test.js.map +0 -1
  195. package/dist/commands/_lib/apply/__tests__/client.test.d.ts +0 -2
  196. package/dist/commands/_lib/apply/__tests__/client.test.d.ts.map +0 -1
  197. package/dist/commands/_lib/apply/__tests__/client.test.js +0 -23
  198. package/dist/commands/_lib/apply/__tests__/client.test.js.map +0 -1
  199. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts +0 -2
  200. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts.map +0 -1
  201. package/dist/commands/_lib/apply/__tests__/desired-state.test.js +0 -140
  202. package/dist/commands/_lib/apply/__tests__/desired-state.test.js.map +0 -1
  203. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts +0 -2
  204. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts.map +0 -1
  205. package/dist/commands/_lib/apply/__tests__/diff.test.js +0 -378
  206. package/dist/commands/_lib/apply/__tests__/diff.test.js.map +0 -1
  207. package/dist/commands/apply.d.ts +0 -3
  208. package/dist/commands/apply.d.ts.map +0 -1
  209. package/dist/commands/apply.js +0 -5
  210. package/dist/commands/apply.js.map +0 -1
  211. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts +0 -2
  212. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts.map +0 -1
  213. package/dist/commands/memory/_lib/openclaw-auth.test.js +0 -9
  214. package/dist/commands/memory/_lib/openclaw-auth.test.js.map +0 -1
  215. package/dist/internal/__tests__/api-client.test.d.ts +0 -2
  216. package/dist/internal/__tests__/api-client.test.d.ts.map +0 -1
  217. package/dist/internal/__tests__/api-client.test.js +0 -95
  218. package/dist/internal/__tests__/api-client.test.js.map +0 -1
  219. package/dist/internal/__tests__/context.test.d.ts +0 -2
  220. package/dist/internal/__tests__/context.test.d.ts.map +0 -1
  221. package/dist/internal/__tests__/context.test.js +0 -77
  222. package/dist/internal/__tests__/context.test.js.map +0 -1
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Spotify Connector (V1 runtime)
3
+ *
4
+ * Syncs saved tracks, playlists, recently played, and top tracks from Spotify.
5
+ * Requires OAuth with user-scoped tokens.
6
+ */
7
+
8
+ import {
9
+ type ActionContext,
10
+ type ActionResult,
11
+ type ConnectorDefinition,
12
+ ConnectorRuntime,
13
+ type EventEnvelope,
14
+ type SyncContext,
15
+ type SyncResult,
16
+ } from '@lobu/connector-sdk';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Spotify API types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ interface SpotifyArtist {
23
+ id: string;
24
+ name: string;
25
+ external_urls: { spotify: string };
26
+ }
27
+
28
+ interface SpotifyImage {
29
+ url: string;
30
+ height: number | null;
31
+ width: number | null;
32
+ }
33
+
34
+ interface SpotifyAlbum {
35
+ id: string;
36
+ name: string;
37
+ images: SpotifyImage[];
38
+ release_date: string;
39
+ external_urls: { spotify: string };
40
+ }
41
+
42
+ interface SpotifyTrack {
43
+ id: string;
44
+ name: string;
45
+ artists: SpotifyArtist[];
46
+ album: SpotifyAlbum;
47
+ duration_ms: number;
48
+ popularity: number;
49
+ explicit: boolean;
50
+ external_urls: { spotify: string };
51
+ uri: string;
52
+ preview_url: string | null;
53
+ }
54
+
55
+ interface SpotifyPagingResponse<T> {
56
+ items: T[];
57
+ total: number;
58
+ limit: number;
59
+ offset: number;
60
+ next: string | null;
61
+ previous: string | null;
62
+ }
63
+
64
+ interface SpotifySavedTrack {
65
+ added_at: string;
66
+ track: SpotifyTrack;
67
+ }
68
+
69
+ interface SpotifyPlaylist {
70
+ id: string;
71
+ name: string;
72
+ description: string | null;
73
+ public: boolean | null;
74
+ collaborative: boolean;
75
+ owner: { id: string; display_name: string | null };
76
+ tracks: { total: number; href: string };
77
+ images: SpotifyImage[];
78
+ external_urls: { spotify: string };
79
+ }
80
+
81
+ interface SpotifyPlaylistTrackItem {
82
+ added_at: string;
83
+ added_by: { id: string };
84
+ track: SpotifyTrack | null;
85
+ }
86
+
87
+ interface SpotifyRecentlyPlayedItem {
88
+ track: SpotifyTrack;
89
+ played_at: string;
90
+ context: { type: string; uri: string; external_urls: { spotify: string } } | null;
91
+ }
92
+
93
+ interface SpotifyRecentlyPlayedResponse {
94
+ items: SpotifyRecentlyPlayedItem[];
95
+ cursors: { after: string; before: string } | null;
96
+ next: string | null;
97
+ }
98
+
99
+ interface SpotifyCheckpoint {
100
+ last_sync_at?: string;
101
+ offset?: number;
102
+ cursor?: string;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Helpers
107
+ // ---------------------------------------------------------------------------
108
+
109
+ function artistNames(artists: SpotifyArtist[]): string {
110
+ return artists.map((a) => a.name).join(', ');
111
+ }
112
+
113
+ function albumArt(images: SpotifyImage[]): string | undefined {
114
+ return images[0]?.url;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Connector
119
+ // ---------------------------------------------------------------------------
120
+
121
+ export default class SpotifyConnector extends ConnectorRuntime {
122
+ readonly definition: ConnectorDefinition = {
123
+ key: 'spotify',
124
+ name: 'Spotify',
125
+ description: 'Syncs saved tracks, playlists, recently played, and top tracks from Spotify.',
126
+ version: '1.0.0',
127
+ faviconDomain: 'spotify.com',
128
+ authSchema: {
129
+ methods: [
130
+ {
131
+ type: 'oauth',
132
+ provider: 'spotify',
133
+ authorizationUrl: 'https://accounts.spotify.com/authorize',
134
+ tokenUrl: 'https://accounts.spotify.com/api/token',
135
+ userinfoUrl: 'https://api.spotify.com/v1/me',
136
+ tokenEndpointAuthMethod: 'client_secret_basic',
137
+ requiredScopes: [
138
+ 'user-read-private',
139
+ 'user-read-email',
140
+ 'user-library-read',
141
+ 'user-top-read',
142
+ 'user-read-recently-played',
143
+ 'playlist-read-private',
144
+ ],
145
+ loginScopes: ['user-read-private', 'user-read-email'],
146
+ clientIdKey: 'SPOTIFY_CLIENT_ID',
147
+ clientSecretKey: 'SPOTIFY_CLIENT_SECRET',
148
+ loginProvisioning: {
149
+ autoCreateConnection: true,
150
+ },
151
+ setupInstructions:
152
+ 'Create a Spotify App at https://developer.spotify.com/dashboard — add {{redirect_uri}} as a Redirect URI, then copy the client ID and secret below.',
153
+ },
154
+ ],
155
+ },
156
+ feeds: {
157
+ saved_tracks: {
158
+ key: 'saved_tracks',
159
+ name: 'Saved Tracks',
160
+ description: 'Your liked/saved tracks on Spotify.',
161
+ displayNameTemplate: 'Saved Tracks',
162
+ requiredScopes: ['user-library-read'],
163
+ eventKinds: {
164
+ track: {
165
+ description: 'A saved Spotify track',
166
+ metadataSchema: {
167
+ type: 'object',
168
+ properties: {
169
+ artist: { type: 'string' },
170
+ album: { type: 'string' },
171
+ album_art_url: { type: 'string', format: 'uri' },
172
+ duration_ms: { type: 'number' },
173
+ popularity: { type: 'number' },
174
+ explicit: { type: 'boolean' },
175
+ release_date: { type: 'string' },
176
+ },
177
+ },
178
+ },
179
+ },
180
+ },
181
+ playlists: {
182
+ key: 'playlists',
183
+ name: 'Playlists',
184
+ description: 'Your playlists and their tracks.',
185
+ displayNameTemplate: 'Playlists',
186
+ requiredScopes: ['playlist-read-private'],
187
+ eventKinds: {
188
+ playlist: {
189
+ description: 'A Spotify playlist',
190
+ metadataSchema: {
191
+ type: 'object',
192
+ properties: {
193
+ track_count: { type: 'number' },
194
+ public: { type: 'boolean' },
195
+ collaborative: { type: 'boolean' },
196
+ owner: { type: 'string' },
197
+ },
198
+ },
199
+ },
200
+ playlist_track: {
201
+ description: 'A track within a Spotify playlist',
202
+ metadataSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ playlist_id: { type: 'string' },
206
+ playlist_name: { type: 'string' },
207
+ artist: { type: 'string' },
208
+ album: { type: 'string' },
209
+ added_at: { type: 'string' },
210
+ added_by: { type: 'string' },
211
+ },
212
+ },
213
+ },
214
+ },
215
+ },
216
+ recently_played: {
217
+ key: 'recently_played',
218
+ name: 'Recently Played',
219
+ description: 'Your recently played tracks.',
220
+ displayNameTemplate: 'Recently Played',
221
+ requiredScopes: ['user-read-recently-played'],
222
+ eventKinds: {
223
+ play: {
224
+ description: 'A recently played track',
225
+ metadataSchema: {
226
+ type: 'object',
227
+ properties: {
228
+ artist: { type: 'string' },
229
+ album: { type: 'string' },
230
+ duration_ms: { type: 'number' },
231
+ context_type: { type: 'string' },
232
+ context_uri: { type: 'string' },
233
+ },
234
+ },
235
+ },
236
+ },
237
+ },
238
+ top_tracks: {
239
+ key: 'top_tracks',
240
+ name: 'Top Tracks',
241
+ description: 'Your top tracks by listening frequency.',
242
+ displayNameTemplate: 'Top Tracks ({time_range})',
243
+ requiredScopes: ['user-top-read'],
244
+ configSchema: {
245
+ type: 'object',
246
+ properties: {
247
+ time_range: {
248
+ type: 'string',
249
+ enum: ['short_term', 'medium_term', 'long_term'],
250
+ default: 'medium_term',
251
+ description:
252
+ 'Time range: short_term (~4 weeks), medium_term (~6 months), long_term (all time).',
253
+ },
254
+ },
255
+ },
256
+ eventKinds: {
257
+ top_track: {
258
+ description: 'A top track by listening frequency',
259
+ metadataSchema: {
260
+ type: 'object',
261
+ properties: {
262
+ artist: { type: 'string' },
263
+ album: { type: 'string' },
264
+ popularity: { type: 'number' },
265
+ rank: { type: 'number' },
266
+ time_range: { type: 'string' },
267
+ },
268
+ },
269
+ },
270
+ },
271
+ },
272
+ },
273
+ };
274
+
275
+ private readonly API_BASE = 'https://api.spotify.com/v1';
276
+ private readonly PAGE_SIZE = 50;
277
+ private readonly MAX_PAGES = 20;
278
+
279
+ // -------------------------------------------------------------------------
280
+ // sync
281
+ // -------------------------------------------------------------------------
282
+
283
+ async sync(ctx: SyncContext): Promise<SyncResult> {
284
+ const accessToken = ctx.credentials?.accessToken;
285
+ if (!accessToken) {
286
+ throw new Error('Spotify requires OAuth authentication.');
287
+ }
288
+
289
+ switch (ctx.feedKey) {
290
+ case 'saved_tracks':
291
+ return this.syncSavedTracks(ctx, accessToken);
292
+ case 'playlists':
293
+ return this.syncPlaylists(ctx, accessToken);
294
+ case 'recently_played':
295
+ return this.syncRecentlyPlayed(ctx, accessToken);
296
+ case 'top_tracks':
297
+ return this.syncTopTracks(ctx, accessToken);
298
+ default:
299
+ throw new Error(`Unknown feed: ${ctx.feedKey}`);
300
+ }
301
+ }
302
+
303
+ // -------------------------------------------------------------------------
304
+ // execute
305
+ // -------------------------------------------------------------------------
306
+
307
+ async execute(_ctx: ActionContext): Promise<ActionResult> {
308
+ return { success: false, error: 'Actions not supported' };
309
+ }
310
+
311
+ // -------------------------------------------------------------------------
312
+ // Feed: saved_tracks
313
+ // -------------------------------------------------------------------------
314
+
315
+ private async syncSavedTracks(ctx: SyncContext, accessToken: string): Promise<SyncResult> {
316
+ const events: EventEnvelope[] = [];
317
+ let offset = 0;
318
+
319
+ for (let page = 0; page < this.MAX_PAGES; page++) {
320
+ const data = await this.spotifyGet<SpotifyPagingResponse<SpotifySavedTrack>>(
321
+ `${this.API_BASE}/me/tracks?limit=${this.PAGE_SIZE}&offset=${offset}`,
322
+ accessToken
323
+ );
324
+
325
+ for (const item of data.items) {
326
+ const track = item.track;
327
+ events.push({
328
+ origin_id: `spotify_track_${track.id}`,
329
+ title: track.name,
330
+ payload_text: `${track.name} by ${artistNames(track.artists)} — ${track.album.name}`,
331
+ author_name: artistNames(track.artists),
332
+ source_url: track.external_urls.spotify,
333
+ occurred_at: new Date(item.added_at),
334
+ origin_type: 'track',
335
+ metadata: {
336
+ artist: artistNames(track.artists),
337
+ album: track.album.name,
338
+ album_art_url: albumArt(track.album.images),
339
+ duration_ms: track.duration_ms,
340
+ popularity: track.popularity,
341
+ explicit: track.explicit,
342
+ release_date: track.album.release_date,
343
+ },
344
+ });
345
+ }
346
+
347
+ if (ctx.emitEvents) await ctx.emitEvents(events.splice(0));
348
+
349
+ if (!data.next) break;
350
+ offset += this.PAGE_SIZE;
351
+ }
352
+
353
+ return {
354
+ events,
355
+ checkpoint: { last_sync_at: new Date().toISOString() } satisfies SpotifyCheckpoint as Record<
356
+ string,
357
+ unknown
358
+ >,
359
+ };
360
+ }
361
+
362
+ // -------------------------------------------------------------------------
363
+ // Feed: playlists
364
+ // -------------------------------------------------------------------------
365
+
366
+ private async syncPlaylists(ctx: SyncContext, accessToken: string): Promise<SyncResult> {
367
+ const events: EventEnvelope[] = [];
368
+
369
+ // First, fetch all playlists
370
+ const playlists: SpotifyPlaylist[] = [];
371
+ let offset = 0;
372
+ for (let page = 0; page < this.MAX_PAGES; page++) {
373
+ const data = await this.spotifyGet<SpotifyPagingResponse<SpotifyPlaylist>>(
374
+ `${this.API_BASE}/me/playlists?limit=${this.PAGE_SIZE}&offset=${offset}`,
375
+ accessToken
376
+ );
377
+ playlists.push(...data.items);
378
+ if (!data.next) break;
379
+ offset += this.PAGE_SIZE;
380
+ }
381
+
382
+ // Emit playlist events
383
+ for (const pl of playlists) {
384
+ events.push({
385
+ origin_id: `spotify_playlist_${pl.id}`,
386
+ title: pl.name,
387
+ payload_text: pl.description ?? pl.name,
388
+ author_name: pl.owner.display_name ?? pl.owner.id,
389
+ source_url: pl.external_urls.spotify,
390
+ occurred_at: new Date(),
391
+ origin_type: 'playlist',
392
+ metadata: {
393
+ track_count: pl.tracks.total,
394
+ public: pl.public,
395
+ collaborative: pl.collaborative,
396
+ owner: pl.owner.display_name ?? pl.owner.id,
397
+ },
398
+ });
399
+ }
400
+
401
+ if (ctx.emitEvents) await ctx.emitEvents(events.splice(0));
402
+
403
+ // Then fetch tracks for each playlist
404
+ for (const pl of playlists) {
405
+ let trackOffset = 0;
406
+ for (let page = 0; page < this.MAX_PAGES; page++) {
407
+ const data = await this.spotifyGet<SpotifyPagingResponse<SpotifyPlaylistTrackItem>>(
408
+ `${this.API_BASE}/playlists/${pl.id}/tracks?limit=${this.PAGE_SIZE}&offset=${trackOffset}`,
409
+ accessToken
410
+ );
411
+
412
+ const trackEvents: EventEnvelope[] = [];
413
+ for (const item of data.items) {
414
+ if (!item.track) continue;
415
+ const track = item.track;
416
+ trackEvents.push({
417
+ origin_id: `spotify_pl_${pl.id}_track_${track.id}`,
418
+ title: track.name,
419
+ payload_text: `${track.name} by ${artistNames(track.artists)}`,
420
+ author_name: artistNames(track.artists),
421
+ source_url: track.external_urls.spotify,
422
+ occurred_at: new Date(item.added_at),
423
+ origin_type: 'playlist_track',
424
+ origin_parent_id: `spotify_playlist_${pl.id}`,
425
+ metadata: {
426
+ playlist_id: pl.id,
427
+ playlist_name: pl.name,
428
+ artist: artistNames(track.artists),
429
+ album: track.album.name,
430
+ added_at: item.added_at,
431
+ added_by: item.added_by.id,
432
+ },
433
+ });
434
+ }
435
+
436
+ if (ctx.emitEvents) await ctx.emitEvents(trackEvents);
437
+ else events.push(...trackEvents);
438
+
439
+ if (!data.next) break;
440
+ trackOffset += this.PAGE_SIZE;
441
+ }
442
+ }
443
+
444
+ return {
445
+ events,
446
+ checkpoint: { last_sync_at: new Date().toISOString() } satisfies SpotifyCheckpoint as Record<
447
+ string,
448
+ unknown
449
+ >,
450
+ };
451
+ }
452
+
453
+ // -------------------------------------------------------------------------
454
+ // Feed: recently_played
455
+ // -------------------------------------------------------------------------
456
+
457
+ private async syncRecentlyPlayed(ctx: SyncContext, accessToken: string): Promise<SyncResult> {
458
+ const events: EventEnvelope[] = [];
459
+ const checkpoint = (ctx.checkpoint ?? {}) as SpotifyCheckpoint;
460
+ let url = `${this.API_BASE}/me/player/recently-played?limit=${this.PAGE_SIZE}`;
461
+
462
+ // Resume from last cursor if available
463
+ if (checkpoint.cursor) {
464
+ url += `&after=${checkpoint.cursor}`;
465
+ }
466
+
467
+ let newCursor: string | undefined;
468
+
469
+ for (let page = 0; page < this.MAX_PAGES; page++) {
470
+ const data = await this.spotifyGet<SpotifyRecentlyPlayedResponse>(url, accessToken);
471
+
472
+ for (const item of data.items) {
473
+ const track = item.track;
474
+ const playedAt = new Date(item.played_at);
475
+ events.push({
476
+ origin_id: `spotify_play_${track.id}_${playedAt.getTime()}`,
477
+ title: track.name,
478
+ payload_text: `${track.name} by ${artistNames(track.artists)}`,
479
+ author_name: artistNames(track.artists),
480
+ source_url: track.external_urls.spotify,
481
+ occurred_at: playedAt,
482
+ origin_type: 'play',
483
+ metadata: {
484
+ artist: artistNames(track.artists),
485
+ album: track.album.name,
486
+ duration_ms: track.duration_ms,
487
+ context_type: item.context?.type,
488
+ context_uri: item.context?.uri,
489
+ },
490
+ });
491
+ }
492
+
493
+ if (ctx.emitEvents) await ctx.emitEvents(events.splice(0));
494
+
495
+ // Store the latest cursor for next sync
496
+ if (data.cursors?.after) {
497
+ newCursor = data.cursors.after;
498
+ }
499
+
500
+ if (!data.next) break;
501
+ url = data.next;
502
+ }
503
+
504
+ return {
505
+ events,
506
+ checkpoint: {
507
+ last_sync_at: new Date().toISOString(),
508
+ ...(newCursor && { cursor: newCursor }),
509
+ } satisfies SpotifyCheckpoint as Record<string, unknown>,
510
+ };
511
+ }
512
+
513
+ // -------------------------------------------------------------------------
514
+ // Feed: top_tracks
515
+ // -------------------------------------------------------------------------
516
+
517
+ private async syncTopTracks(ctx: SyncContext, accessToken: string): Promise<SyncResult> {
518
+ const timeRange = (ctx.config.time_range as string) ?? 'medium_term';
519
+ const events: EventEnvelope[] = [];
520
+ let offset = 0;
521
+ let rank = 1;
522
+
523
+ for (let page = 0; page < this.MAX_PAGES; page++) {
524
+ const data = await this.spotifyGet<SpotifyPagingResponse<SpotifyTrack>>(
525
+ `${this.API_BASE}/me/top/tracks?time_range=${timeRange}&limit=${this.PAGE_SIZE}&offset=${offset}`,
526
+ accessToken
527
+ );
528
+
529
+ for (const track of data.items) {
530
+ events.push({
531
+ origin_id: `spotify_top_${timeRange}_${track.id}`,
532
+ title: `#${rank} ${track.name}`,
533
+ payload_text: `${track.name} by ${artistNames(track.artists)} — ${track.album.name}`,
534
+ author_name: artistNames(track.artists),
535
+ source_url: track.external_urls.spotify,
536
+ occurred_at: new Date(),
537
+ origin_type: 'top_track',
538
+ metadata: {
539
+ artist: artistNames(track.artists),
540
+ album: track.album.name,
541
+ popularity: track.popularity,
542
+ rank,
543
+ time_range: timeRange,
544
+ },
545
+ });
546
+ rank++;
547
+ }
548
+
549
+ if (ctx.emitEvents) await ctx.emitEvents(events.splice(0));
550
+
551
+ if (!data.next) break;
552
+ offset += this.PAGE_SIZE;
553
+ }
554
+
555
+ return {
556
+ events,
557
+ checkpoint: { last_sync_at: new Date().toISOString() } satisfies SpotifyCheckpoint as Record<
558
+ string,
559
+ unknown
560
+ >,
561
+ };
562
+ }
563
+
564
+ // -------------------------------------------------------------------------
565
+ // API helpers
566
+ // -------------------------------------------------------------------------
567
+
568
+ private async spotifyGet<T>(url: string, accessToken: string): Promise<T> {
569
+ const response = await fetch(url, {
570
+ headers: { Authorization: `Bearer ${accessToken}` },
571
+ });
572
+
573
+ if (response.status === 429) {
574
+ const retryAfter = response.headers.get('Retry-After');
575
+ throw new Error(
576
+ `Spotify rate limit exceeded. Retry after ${retryAfter ?? 'unknown'} seconds.`
577
+ );
578
+ }
579
+
580
+ if (response.status === 401) {
581
+ throw new Error('Spotify access token expired or invalid.');
582
+ }
583
+
584
+ if (!response.ok) {
585
+ throw new Error(`Spotify API error (${response.status}): ${await response.text()}`);
586
+ }
587
+
588
+ return response.json() as Promise<T>;
589
+ }
590
+ }