@lobu/cli 6.0.1 → 7.0.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 (265) hide show
  1. package/README.md +20 -27
  2. package/dist/bundled-skills/lobu/SKILL.md +11 -11
  3. package/dist/commands/_lib/apply/apply-cmd.d.ts +38 -0
  4. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  5. package/dist/commands/_lib/apply/apply-cmd.js +574 -40
  6. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  7. package/dist/commands/_lib/apply/client.d.ts +180 -1
  8. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  9. package/dist/commands/_lib/apply/client.js +308 -28
  10. package/dist/commands/_lib/apply/client.js.map +1 -1
  11. package/dist/commands/_lib/apply/desired-state.d.ts +134 -3
  12. package/dist/commands/_lib/apply/desired-state.d.ts.map +1 -1
  13. package/dist/commands/_lib/apply/desired-state.js +703 -89
  14. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  15. package/dist/commands/_lib/apply/diff.d.ts +61 -3
  16. package/dist/commands/_lib/apply/diff.d.ts.map +1 -1
  17. package/dist/commands/_lib/apply/diff.js +382 -92
  18. package/dist/commands/_lib/apply/diff.js.map +1 -1
  19. package/dist/commands/_lib/apply/prompt.d.ts +6 -0
  20. package/dist/commands/_lib/apply/prompt.d.ts.map +1 -1
  21. package/dist/commands/_lib/apply/prompt.js +16 -0
  22. package/dist/commands/_lib/apply/prompt.js.map +1 -1
  23. package/dist/commands/_lib/apply/render.d.ts +9 -0
  24. package/dist/commands/_lib/apply/render.d.ts.map +1 -1
  25. package/dist/commands/_lib/apply/render.js +80 -3
  26. package/dist/commands/_lib/apply/render.js.map +1 -1
  27. package/dist/commands/agent.d.ts +7 -0
  28. package/dist/commands/agent.d.ts.map +1 -1
  29. package/dist/commands/agent.js +65 -1
  30. package/dist/commands/agent.js.map +1 -1
  31. package/dist/commands/chat.d.ts +12 -9
  32. package/dist/commands/chat.d.ts.map +1 -1
  33. package/dist/commands/chat.js +125 -57
  34. package/dist/commands/chat.js.map +1 -1
  35. package/dist/commands/dev.d.ts +23 -7
  36. package/dist/commands/dev.d.ts.map +1 -1
  37. package/dist/commands/dev.js +197 -49
  38. package/dist/commands/dev.js.map +1 -1
  39. package/dist/commands/doctor.d.ts +1 -0
  40. package/dist/commands/doctor.d.ts.map +1 -1
  41. package/dist/commands/doctor.js +136 -0
  42. package/dist/commands/doctor.js.map +1 -1
  43. package/dist/commands/eval.d.ts +8 -0
  44. package/dist/commands/eval.d.ts.map +1 -1
  45. package/dist/commands/eval.js +72 -6
  46. package/dist/commands/eval.js.map +1 -1
  47. package/dist/commands/init.d.ts +22 -5
  48. package/dist/commands/init.d.ts.map +1 -1
  49. package/dist/commands/init.js +355 -182
  50. package/dist/commands/init.js.map +1 -1
  51. package/dist/commands/link.d.ts +11 -0
  52. package/dist/commands/link.d.ts.map +1 -0
  53. package/dist/commands/link.js +28 -0
  54. package/dist/commands/link.js.map +1 -0
  55. package/dist/commands/login.d.ts.map +1 -1
  56. package/dist/commands/login.js +14 -2
  57. package/dist/commands/login.js.map +1 -1
  58. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  59. package/dist/commands/memory/_lib/browser-auth-cmd.js +3 -3
  60. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  61. package/dist/commands/memory/_lib/mcp.d.ts +2 -2
  62. package/dist/commands/memory/_lib/mcp.d.ts.map +1 -1
  63. package/dist/commands/memory/_lib/mcp.js +24 -12
  64. package/dist/commands/memory/_lib/mcp.js.map +1 -1
  65. package/dist/commands/memory/_lib/openclaw-auth.d.ts +1 -0
  66. package/dist/commands/memory/_lib/openclaw-auth.d.ts.map +1 -1
  67. package/dist/commands/memory/_lib/openclaw-auth.js +14 -3
  68. package/dist/commands/memory/_lib/openclaw-auth.js.map +1 -1
  69. package/dist/commands/memory/_lib/openclaw-cmd.js +1 -1
  70. package/dist/commands/memory/_lib/openclaw-cmd.js.map +1 -1
  71. package/dist/commands/memory/_lib/schema.d.ts +29 -2
  72. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  73. package/dist/commands/memory/_lib/schema.js +121 -5
  74. package/dist/commands/memory/_lib/schema.js.map +1 -1
  75. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  76. package/dist/commands/memory/_lib/seed-cmd.js +46 -24
  77. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  78. package/dist/commands/memory/run.d.ts.map +1 -1
  79. package/dist/commands/memory/run.js +2 -2
  80. package/dist/commands/memory/run.js.map +1 -1
  81. package/dist/commands/org.d.ts +4 -0
  82. package/dist/commands/org.d.ts.map +1 -1
  83. package/dist/commands/org.js +10 -0
  84. package/dist/commands/org.js.map +1 -1
  85. package/dist/commands/platforms/platform-prompts.d.ts +0 -1
  86. package/dist/commands/platforms/platform-prompts.d.ts.map +1 -1
  87. package/dist/commands/platforms/platform-prompts.js +54 -8
  88. package/dist/commands/platforms/platform-prompts.js.map +1 -1
  89. package/dist/commands/telemetry.d.ts +10 -0
  90. package/dist/commands/telemetry.d.ts.map +1 -0
  91. package/dist/commands/telemetry.js +68 -0
  92. package/dist/commands/telemetry.js.map +1 -0
  93. package/dist/commands/token.d.ts +9 -0
  94. package/dist/commands/token.d.ts.map +1 -1
  95. package/dist/commands/token.js +54 -0
  96. package/dist/commands/token.js.map +1 -1
  97. package/dist/commands/whoami.d.ts.map +1 -1
  98. package/dist/commands/whoami.js +1 -1
  99. package/dist/commands/whoami.js.map +1 -1
  100. package/dist/connectors/README.md +534 -0
  101. package/dist/connectors/__tests__/browser-scraper-utils.test.ts +186 -0
  102. package/dist/connectors/apple_health.ts +138 -0
  103. package/dist/connectors/apple_screen_time.ts +82 -0
  104. package/dist/connectors/browser-scraper-utils.ts +246 -0
  105. package/dist/connectors/capterra.ts +277 -0
  106. package/dist/connectors/g2.ts +290 -0
  107. package/dist/connectors/github.ts +1530 -0
  108. package/dist/connectors/glassdoor.ts +295 -0
  109. package/dist/connectors/gmaps.ts +197 -0
  110. package/dist/connectors/google_calendar.ts +641 -0
  111. package/dist/connectors/google_gmail.ts +754 -0
  112. package/dist/connectors/google_photos.ts +776 -0
  113. package/dist/connectors/google_play.ts +349 -0
  114. package/dist/connectors/hackernews.ts +471 -0
  115. package/dist/connectors/index.ts +28 -0
  116. package/dist/connectors/ios_appstore.ts +226 -0
  117. package/dist/connectors/linkedin.ts +494 -0
  118. package/dist/connectors/local_directory.ts +91 -0
  119. package/dist/connectors/microsoft_outlook.ts +410 -0
  120. package/dist/connectors/producthunt.ts +471 -0
  121. package/dist/connectors/reddit.ts +600 -0
  122. package/dist/connectors/revolut.ts +572 -0
  123. package/dist/connectors/rss.ts +448 -0
  124. package/dist/connectors/spotify.ts +590 -0
  125. package/dist/connectors/trustpilot.ts +203 -0
  126. package/dist/connectors/website.ts +629 -0
  127. package/dist/connectors/whatsapp.ts +1081 -0
  128. package/dist/connectors/whatsapp_local.ts +125 -0
  129. package/dist/connectors/x.ts +536 -0
  130. package/dist/connectors/youtube.ts +666 -0
  131. package/dist/db/migrations/00000000000000_baseline.sql +4867 -0
  132. package/dist/db/migrations/20260405193000_add_mcp_sessions.sql +33 -0
  133. package/dist/db/migrations/20260408120000_remove_system_connectors.sql +48 -0
  134. package/dist/db/migrations/20260408120001_optional_compiled_code.sql +6 -0
  135. package/dist/db/migrations/20260409110000_add_active_watcher_run_index.sql +9 -0
  136. package/dist/db/migrations/20260409130000_connector_default_config.sql +5 -0
  137. package/dist/db/migrations/20260410120000_add_agent_secrets.sql +25 -0
  138. package/dist/db/migrations/20260413170000_add_watcher_group_id.sql +67 -0
  139. package/dist/db/migrations/20260416120000_add_entity_wa_jid_index.sql +14 -0
  140. package/dist/db/migrations/20260417100000_add_entity_identities.sql +77 -0
  141. package/dist/db/migrations/20260418100000_add_auth_runs.sql +83 -0
  142. package/dist/db/migrations/20260418110000_add_runs_created_by_user.sql +18 -0
  143. package/dist/db/migrations/20260419120000_add_event_identity_indexes.sql +56 -0
  144. package/dist/db/migrations/20260420120000_extend_reserved_org_slugs.sql +56 -0
  145. package/dist/db/migrations/20260424030000_add_watcher_run_correlation.sql +52 -0
  146. package/dist/db/migrations/20260424130000_relax_events_client_id_fk.sql +47 -0
  147. package/dist/db/migrations/20260425100000_normalize_watcher_feedback.sql +91 -0
  148. package/dist/db/migrations/20260425120000_add_run_diagnostics.sql +20 -0
  149. package/dist/db/migrations/20260425130000_add_repair_agent_plumbing.sql +46 -0
  150. package/dist/db/migrations/20260426120000_entities_entity_type_fk.sql +101 -0
  151. package/dist/db/migrations/20260426130000_db_integrity_cleanup.sql +104 -0
  152. package/dist/db/migrations/20260426130001_db_integrity_cleanup_concurrent.sql +187 -0
  153. package/dist/db/migrations/20260427133000_events_created_by_nullable.sql +74 -0
  154. package/dist/db/migrations/20260427140000_identity_engine_indexes.sql +140 -0
  155. package/dist/db/migrations/20260427150000_drop_events_source_id.sql +177 -0
  156. package/dist/db/migrations/20260427160000_drop_dead_schema.sql +76 -0
  157. package/dist/db/migrations/20260427170000_market_founder_to_member.sql +364 -0
  158. package/dist/db/migrations/20260428040000_cascade_events_watchers_org_fk.sql +66 -0
  159. package/dist/db/migrations/20260428050000_add_runs_approved_input.sql +9 -0
  160. package/dist/db/migrations/20260429010000_auth_profile_tenant_scoped_fk.sql +79 -0
  161. package/dist/db/migrations/20260429060000_extend_runs_for_lobu_queue.sql +108 -0
  162. package/dist/db/migrations/20260429120000_agent_changed_notify.sql +97 -0
  163. package/dist/db/migrations/20260429120100_user_auth_profiles_and_model_prefs.sql +36 -0
  164. package/dist/db/migrations/20260429120200_fix_notify_old_keys.sql +130 -0
  165. package/dist/db/migrations/20260429130000_oauth_states_cli_sessions_rate_limits.sql +83 -0
  166. package/dist/db/migrations/20260429140000_phase8_grants_chat_connections_mcp_sessions.sql +84 -0
  167. package/dist/db/migrations/20260429140100_runs_priority_expires_at_retry_delay.sql +44 -0
  168. package/dist/db/migrations/20260429180000_drop_invalidatable_cache_triggers.sql +25 -0
  169. package/dist/db/migrations/20260430005614_agents_apply_fields.sql +21 -0
  170. package/dist/db/migrations/20260430022231_fix_connection_config_encryption.sql +69 -0
  171. package/dist/db/migrations/20260430151215_add_task_run_type.sql +77 -0
  172. package/dist/db/migrations/20260501000000_drop_cli_sessions.sql +27 -0
  173. package/dist/db/migrations/20260501133000_lobu_memory_mcp_id.sql +117 -0
  174. package/dist/db/migrations/20260502000000_drop_chat_connections.sql +60 -0
  175. package/dist/db/migrations/20260503000000_agent_secrets_org_scope.sql +56 -0
  176. package/dist/db/migrations/20260504000000_flatten_agents_drop_sandbox_model.sql +48 -0
  177. package/dist/db/migrations/20260510220000_connector_required_capability.sql +47 -0
  178. package/dist/db/migrations/20260512000000_device_worker_connection_binding.sql +113 -0
  179. package/dist/db/migrations/20260512131703_connections_slug.sql +131 -0
  180. package/dist/db/migrations/20260513000000_chat_user_identities.sql +24 -0
  181. package/dist/db/migrations/20260513120000_auth_profiles_device_binding.sql +50 -0
  182. package/dist/db/migrations/20260513150000_auth_profiles_cdp_url.sql +43 -0
  183. package/dist/db/migrations/20260513200000_notifications_as_events.sql +86 -0
  184. package/dist/db/migrations/20260514000000_scheduled_jobs.sql +97 -0
  185. package/dist/db/migrations/20260514120000_auth_profiles_connector_key_nullable.sql +42 -0
  186. package/dist/eval/types.d.ts +2 -0
  187. package/dist/eval/types.d.ts.map +1 -1
  188. package/dist/index.d.ts +11 -0
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +210 -132
  191. package/dist/index.js.map +1 -1
  192. package/dist/internal/api-client.d.ts +4 -8
  193. package/dist/internal/api-client.d.ts.map +1 -1
  194. package/dist/internal/api-client.js +1 -1
  195. package/dist/internal/api-client.js.map +1 -1
  196. package/dist/internal/context.js +2 -2
  197. package/dist/internal/context.js.map +1 -1
  198. package/dist/internal/credentials.d.ts.map +1 -1
  199. package/dist/internal/credentials.js +6 -1
  200. package/dist/internal/credentials.js.map +1 -1
  201. package/dist/internal/gateway-url.d.ts +14 -0
  202. package/dist/internal/gateway-url.d.ts.map +1 -1
  203. package/dist/internal/gateway-url.js +19 -0
  204. package/dist/internal/gateway-url.js.map +1 -1
  205. package/dist/internal/index.d.ts +3 -4
  206. package/dist/internal/index.d.ts.map +1 -1
  207. package/dist/internal/index.js +3 -3
  208. package/dist/internal/index.js.map +1 -1
  209. package/dist/internal/oauth.d.ts +6 -5
  210. package/dist/internal/oauth.d.ts.map +1 -1
  211. package/dist/internal/oauth.js +2 -2
  212. package/dist/internal/project-link.d.ts +10 -0
  213. package/dist/internal/project-link.d.ts.map +1 -0
  214. package/dist/internal/project-link.js +48 -0
  215. package/dist/internal/project-link.js.map +1 -0
  216. package/dist/providers.json +2 -2
  217. package/dist/server.bundle.mjs +31654 -30866
  218. package/dist/start-local.bundle.mjs +74409 -0
  219. package/dist/templates/README.md.tmpl +10 -11
  220. package/dist/templates/TESTING.md.tmpl +9 -9
  221. package/package.json +15 -13
  222. package/dist/__tests__/chat.integration.test.d.ts +0 -2
  223. package/dist/__tests__/chat.integration.test.d.ts.map +0 -1
  224. package/dist/__tests__/chat.integration.test.js +0 -337
  225. package/dist/__tests__/chat.integration.test.js.map +0 -1
  226. package/dist/__tests__/dev.test.d.ts +0 -2
  227. package/dist/__tests__/dev.test.d.ts.map +0 -1
  228. package/dist/__tests__/dev.test.js +0 -25
  229. package/dist/__tests__/dev.test.js.map +0 -1
  230. package/dist/__tests__/init-memory.test.d.ts +0 -2
  231. package/dist/__tests__/init-memory.test.d.ts.map +0 -1
  232. package/dist/__tests__/init-memory.test.js +0 -45
  233. package/dist/__tests__/init-memory.test.js.map +0 -1
  234. package/dist/__tests__/token.test.d.ts +0 -2
  235. package/dist/__tests__/token.test.d.ts.map +0 -1
  236. package/dist/__tests__/token.test.js +0 -52
  237. package/dist/__tests__/token.test.js.map +0 -1
  238. package/dist/commands/_lib/apply/__tests__/client.test.d.ts +0 -2
  239. package/dist/commands/_lib/apply/__tests__/client.test.d.ts.map +0 -1
  240. package/dist/commands/_lib/apply/__tests__/client.test.js +0 -23
  241. package/dist/commands/_lib/apply/__tests__/client.test.js.map +0 -1
  242. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts +0 -2
  243. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts.map +0 -1
  244. package/dist/commands/_lib/apply/__tests__/desired-state.test.js +0 -140
  245. package/dist/commands/_lib/apply/__tests__/desired-state.test.js.map +0 -1
  246. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts +0 -2
  247. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts.map +0 -1
  248. package/dist/commands/_lib/apply/__tests__/diff.test.js +0 -378
  249. package/dist/commands/_lib/apply/__tests__/diff.test.js.map +0 -1
  250. package/dist/commands/apply.d.ts +0 -3
  251. package/dist/commands/apply.d.ts.map +0 -1
  252. package/dist/commands/apply.js +0 -5
  253. package/dist/commands/apply.js.map +0 -1
  254. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts +0 -2
  255. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts.map +0 -1
  256. package/dist/commands/memory/_lib/openclaw-auth.test.js +0 -9
  257. package/dist/commands/memory/_lib/openclaw-auth.test.js.map +0 -1
  258. package/dist/internal/__tests__/api-client.test.d.ts +0 -2
  259. package/dist/internal/__tests__/api-client.test.d.ts.map +0 -1
  260. package/dist/internal/__tests__/api-client.test.js +0 -95
  261. package/dist/internal/__tests__/api-client.test.js.map +0 -1
  262. package/dist/internal/__tests__/context.test.d.ts +0 -2
  263. package/dist/internal/__tests__/context.test.d.ts.map +0 -1
  264. package/dist/internal/__tests__/context.test.js +0 -77
  265. 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
+ }