ff1-cli 1.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.
@@ -0,0 +1,870 @@
1
+ /**
2
+ * Orchestrator - Function Calling Declarations
3
+ * Contains function schemas and orchestration logic for AI-driven playlist building
4
+ */
5
+ const chalk = require('chalk');
6
+ const registry = require('./registry');
7
+ function sleep(ms) {
8
+ return new Promise((resolve) => setTimeout(resolve, ms));
9
+ }
10
+ async function createCompletionWithRetry(client, requestParams, maxRetries = 0) {
11
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
12
+ try {
13
+ return await client.chat.completions.create(requestParams);
14
+ }
15
+ catch (error) {
16
+ const status = error?.response?.status ?? error?.status;
17
+ if (status === 429 && attempt < maxRetries) {
18
+ const retryAfterHeader = error?.response?.headers?.['retry-after'] || error?.response?.headers?.['Retry-After'];
19
+ const retryAfterMs = retryAfterHeader ? Number(retryAfterHeader) * 1000 : null;
20
+ const backoffMs = Math.min(10000, 2000 * Math.pow(2, attempt));
21
+ const delayMs = retryAfterMs && !Number.isNaN(retryAfterMs) ? retryAfterMs : backoffMs;
22
+ await sleep(delayMs);
23
+ continue;
24
+ }
25
+ throw error;
26
+ }
27
+ }
28
+ throw new Error('Failed to create chat completion');
29
+ }
30
+ /**
31
+ * Function schemas for playlist building
32
+ */
33
+ const functionSchemas = [
34
+ {
35
+ type: 'function',
36
+ function: {
37
+ name: 'query_requirement',
38
+ description: 'Query data for a requirement. Supports build_playlist (blockchain NFTs), query_address (all NFTs from address), and fetch_feed (feed playlists) types.',
39
+ parameters: {
40
+ type: 'object',
41
+ properties: {
42
+ requirement: {
43
+ type: 'object',
44
+ description: 'The COMPLETE requirement object from params. Pass ALL fields from the original requirement without modification, truncation, or omission.',
45
+ properties: {
46
+ type: {
47
+ type: 'string',
48
+ enum: ['build_playlist', 'fetch_feed', 'query_address'],
49
+ description: 'Type of requirement',
50
+ },
51
+ blockchain: {
52
+ type: 'string',
53
+ description: 'Blockchain network (REQUIRED for build_playlist)',
54
+ },
55
+ contractAddress: {
56
+ type: 'string',
57
+ description: 'FULL NFT contract address without truncation (REQUIRED for build_playlist)',
58
+ },
59
+ tokenIds: {
60
+ type: 'array',
61
+ description: 'COMPLETE array of ALL token IDs without truncation (REQUIRED for build_playlist)',
62
+ items: {
63
+ type: 'string',
64
+ },
65
+ },
66
+ ownerAddress: {
67
+ type: 'string',
68
+ description: 'Owner wallet address (0x... for Ethereum, tz... for Tezos) - REQUIRED for query_address',
69
+ },
70
+ playlistName: {
71
+ type: 'string',
72
+ description: 'Feed playlist name (REQUIRED for fetch_feed)',
73
+ },
74
+ quantity: {
75
+ type: ['number', 'string'],
76
+ description: 'Maximum number of items to fetch. Can be a number for specific count, or "all" to fetch all available tokens with pagination (optional for all types, enables random selection for query_address when numeric)',
77
+ },
78
+ },
79
+ required: ['type'],
80
+ },
81
+ duration: {
82
+ type: 'number',
83
+ description: 'Display duration per item in seconds',
84
+ },
85
+ },
86
+ required: ['requirement', 'duration'],
87
+ },
88
+ },
89
+ },
90
+ {
91
+ type: 'function',
92
+ function: {
93
+ name: 'search_feed_playlist',
94
+ description: 'Search for playlists across ALL configured feeds by name. Uses fuzzy matching to automatically find and return the BEST matching playlist name.',
95
+ parameters: {
96
+ type: 'object',
97
+ properties: {
98
+ playlistName: {
99
+ type: 'string',
100
+ description: 'Playlist name to search for',
101
+ },
102
+ },
103
+ required: ['playlistName'],
104
+ },
105
+ },
106
+ },
107
+ {
108
+ type: 'function',
109
+ function: {
110
+ name: 'fetch_feed_playlist_items',
111
+ description: 'Fetch items from a specific feed playlist by NAME (not ID). Pass the exact playlist name you selected. Items will be shuffled and randomly selected based on quantity.',
112
+ parameters: {
113
+ type: 'object',
114
+ properties: {
115
+ playlistName: {
116
+ type: 'string',
117
+ description: 'Exact playlist name (title) selected from search results',
118
+ },
119
+ quantity: {
120
+ type: 'number',
121
+ description: 'Number of random items to fetch (will be shuffled)',
122
+ },
123
+ duration: {
124
+ type: 'number',
125
+ description: 'Duration per item in seconds',
126
+ },
127
+ },
128
+ required: ['playlistName', 'quantity', 'duration'],
129
+ },
130
+ },
131
+ },
132
+ {
133
+ type: 'function',
134
+ function: {
135
+ name: 'build_playlist',
136
+ description: 'Build a DP1 v1.0.0 compliant playlist from collected item IDs. Pass the id field from each item returned by query_requirement.',
137
+ parameters: {
138
+ type: 'object',
139
+ properties: {
140
+ itemIds: {
141
+ type: 'array',
142
+ description: 'Array of item IDs (from id field) collected from query_requirement calls. Example: ["uuid-1", "uuid-2"]',
143
+ items: {
144
+ type: 'string',
145
+ },
146
+ },
147
+ title: {
148
+ type: ['string', 'null'],
149
+ description: 'Playlist title. Pass null for auto-generation.',
150
+ },
151
+ slug: {
152
+ type: ['string', 'null'],
153
+ description: 'Playlist slug. Pass null for auto-generation.',
154
+ },
155
+ shuffle: {
156
+ type: 'boolean',
157
+ description: 'Whether to shuffle items',
158
+ },
159
+ },
160
+ required: ['itemIds'],
161
+ },
162
+ },
163
+ },
164
+ {
165
+ type: 'function',
166
+ function: {
167
+ name: 'send_to_device',
168
+ description: 'Send verified playlist to an FF1 device. Pass the playlistId from build_playlist.',
169
+ parameters: {
170
+ type: 'object',
171
+ properties: {
172
+ playlistId: {
173
+ type: 'string',
174
+ description: 'Playlist ID from build_playlist',
175
+ },
176
+ deviceName: {
177
+ type: ['string', 'null'],
178
+ description: 'Device name (pass null for first device)',
179
+ },
180
+ },
181
+ required: ['playlistId'],
182
+ },
183
+ },
184
+ },
185
+ {
186
+ type: 'function',
187
+ function: {
188
+ name: 'resolve_domains',
189
+ description: 'Resolve blockchain domain names to their wallet addresses. Supports ENS (.eth) and TNS (.tez) domains. Processes domains in batch for efficiency.',
190
+ parameters: {
191
+ type: 'object',
192
+ properties: {
193
+ domains: {
194
+ type: 'array',
195
+ description: 'Array of domain names to resolve (e.g., ["vitalik.eth", "alice.tez"])',
196
+ items: {
197
+ type: 'string',
198
+ },
199
+ },
200
+ displayResults: {
201
+ type: 'boolean',
202
+ description: 'Whether to display resolution results to user (default: true)',
203
+ },
204
+ },
205
+ required: ['domains'],
206
+ },
207
+ },
208
+ },
209
+ {
210
+ type: 'function',
211
+ function: {
212
+ name: 'verify_playlist',
213
+ description: 'Verify a playlist against the DP-1 specification. Pass the playlistId returned from build_playlist.',
214
+ parameters: {
215
+ type: 'object',
216
+ properties: {
217
+ playlistId: {
218
+ type: 'string',
219
+ description: 'Playlist ID returned from build_playlist (e.g., the playlistId field)',
220
+ },
221
+ },
222
+ required: ['playlistId'],
223
+ },
224
+ },
225
+ },
226
+ ];
227
+ // Store playlistMap across function calls
228
+ let globalPlaylistMap = {};
229
+ /**
230
+ * Execute a function call
231
+ *
232
+ * @param {string} functionName - Function name
233
+ * @param {Object} args - Function arguments
234
+ * @returns {Promise<any>} Function result
235
+ */
236
+ async function executeFunction(functionName, args) {
237
+ const utilities = require('../utilities');
238
+ switch (functionName) {
239
+ case 'query_requirement': {
240
+ const items = await utilities.queryRequirement(args.requirement, args.duration);
241
+ // Store full items in registry
242
+ items.forEach((item) => {
243
+ if (item.id) {
244
+ registry.storeItem(item.id, item);
245
+ }
246
+ });
247
+ // Return only minimal metadata for AI context
248
+ return items.map((item) => ({
249
+ id: item.id,
250
+ title: item.title,
251
+ source: item.source?.substring(0, 50) + '...',
252
+ duration: item.duration,
253
+ license: item.license,
254
+ provenance: item.provenance
255
+ ? {
256
+ type: item.provenance.type,
257
+ contract: item.provenance.contract
258
+ ? {
259
+ chain: item.provenance.contract.chain,
260
+ address: item.provenance.contract.address?.substring(0, 10) + '...',
261
+ tokenId: item.provenance.contract.tokenId,
262
+ }
263
+ : undefined,
264
+ }
265
+ : undefined,
266
+ }));
267
+ }
268
+ case 'search_feed_playlist': {
269
+ const result = await utilities.feedFetcher.searchFeedPlaylists(args.playlistName);
270
+ // Store playlistMap for later lookup
271
+ if (result.playlistMap) {
272
+ globalPlaylistMap = result.playlistMap;
273
+ }
274
+ // Return best match found by fuzzy matching
275
+ return {
276
+ success: result.success,
277
+ bestMatch: result.bestMatch,
278
+ searchTerm: result.searchTerm,
279
+ error: result.error,
280
+ message: result.bestMatch
281
+ ? `Found best matching playlist: "${result.bestMatch}"`
282
+ : undefined,
283
+ };
284
+ }
285
+ case 'fetch_feed_playlist_items':
286
+ return await utilities.feedFetcher.fetchPlaylistItems(args.playlistName, args.quantity, args.duration, globalPlaylistMap);
287
+ case 'build_playlist': {
288
+ // Retrieve full items from registry using IDs
289
+ const itemIds = args.itemIds;
290
+ if (!Array.isArray(itemIds) || itemIds.length === 0) {
291
+ throw new Error('build_playlist requires itemIds array');
292
+ }
293
+ const fullItems = itemIds
294
+ .map((id) => registry.getItem(id))
295
+ .filter((item) => item !== undefined);
296
+ if (fullItems.length === 0) {
297
+ throw new Error('No valid items found in registry for provided IDs');
298
+ }
299
+ // Apply shuffle if requested
300
+ const items = args.shuffle ? utilities.shuffleArray([...fullItems]) : fullItems;
301
+ // Build playlist
302
+ const title = args.title === 'null' || args.title === null ? null : args.title;
303
+ const slug = args.slug === 'null' || args.slug === null ? null : args.slug;
304
+ const playlist = await utilities.buildDP1Playlist(items, title, slug);
305
+ // Store in registry
306
+ registry.storePlaylist(playlist.id, playlist);
307
+ // Return minimal metadata
308
+ return {
309
+ playlistId: playlist.id,
310
+ itemCount: playlist.items.length,
311
+ title: playlist.title,
312
+ dpVersion: playlist.dpVersion,
313
+ hasSigned: !!playlist.signature,
314
+ slug: playlist.slug,
315
+ };
316
+ }
317
+ case 'send_to_device': {
318
+ // Retrieve playlist from registry
319
+ const playlistId = args.playlistId;
320
+ if (!playlistId || !registry.hasPlaylist(playlistId)) {
321
+ throw new Error('Invalid playlistId or playlist not found in registry');
322
+ }
323
+ const playlist = registry.getPlaylist(playlistId);
324
+ const result = await utilities.sendToDevice(playlist, args.deviceName);
325
+ // Return minimal response
326
+ return {
327
+ success: result.success,
328
+ deviceName: result.deviceName,
329
+ message: result.message,
330
+ error: result.error,
331
+ };
332
+ }
333
+ case 'resolve_domains':
334
+ return await utilities.resolveDomains(args);
335
+ case 'verify_playlist': {
336
+ const { verifyPlaylist } = require('../utilities/functions');
337
+ // Retrieve playlist from registry
338
+ const playlistId = args.playlistId;
339
+ if (!playlistId || !registry.hasPlaylist(playlistId)) {
340
+ throw new Error('Invalid playlistId or playlist not found in registry');
341
+ }
342
+ const playlist = registry.getPlaylist(playlistId);
343
+ const result = await verifyPlaylist({ playlist });
344
+ // Return minimal response
345
+ if (result.valid) {
346
+ return {
347
+ valid: true,
348
+ playlistId: playlistId,
349
+ itemCount: playlist.items.length,
350
+ };
351
+ }
352
+ else {
353
+ // Only return first 3 errors to save context
354
+ return {
355
+ valid: false,
356
+ playlistId: playlistId,
357
+ error: result.error,
358
+ details: result.details?.slice(0, 3) || [],
359
+ };
360
+ }
361
+ }
362
+ default:
363
+ throw new Error(`Unknown function: ${functionName}`);
364
+ }
365
+ }
366
+ /**
367
+ * Build system prompt for AI orchestrator
368
+ *
369
+ * @param {Object} params - Validated parameters from intent parser
370
+ * @returns {string} System prompt
371
+ */
372
+ function buildOrchestratorSystemPrompt(params) {
373
+ const { requirements, playlistSettings } = params;
374
+ const requirementsText = requirements
375
+ .map((req, i) => {
376
+ if (req.type === 'fetch_feed') {
377
+ return `${i + 1}. Fetch ${req.quantity || 5} items from playlist "${req.playlistName}"`;
378
+ }
379
+ else if (req.type === 'query_address') {
380
+ const quantityText = req.quantity === 'all' ? 'all ' : req.quantity ? req.quantity + ' random ' : 'all ';
381
+ return `${i + 1}. Query ${quantityText}tokens from address ${req.ownerAddress}`;
382
+ }
383
+ else {
384
+ return (`${i + 1}. ${req.blockchain} - ${req.tokenIds?.length || 0} tokens` +
385
+ (req.contractAddress ? ` from ${req.contractAddress.substring(0, 10)}...` : ''));
386
+ }
387
+ })
388
+ .join('\n');
389
+ const hasDevice = playlistSettings.deviceName !== undefined;
390
+ const sendStep = hasDevice
391
+ ? `6) If verification passed → you MUST call send_to_device({ playlistId: <the_playlistId>, deviceName: "${playlistSettings.deviceName || 'first-device'}" }) before finishing.
392
+ CRITICAL: Pass the playlistId string from step 4.`
393
+ : `6) Verification passed → you're done. Do not send to device.`;
394
+ return `SYSTEM: FF1 Orchestrator (Function-Calling)
395
+
396
+ ROLE
397
+ - Execute parsed requirements deterministically and build a DP‑1 playlist. Keep outputs concise and operational.
398
+
399
+ REQUIREMENTS
400
+ ${requirementsText}
401
+
402
+ PLAYLIST SETTINGS
403
+ - durationPerItem: ${playlistSettings.durationPerItem || 10}
404
+ - title: ${playlistSettings.title || 'auto'}
405
+ - slug: ${playlistSettings.slug || 'auto'}
406
+ - preserveOrder: ${playlistSettings.preserveOrder !== false ? 'true' : 'false'}
407
+ ${hasDevice ? `- deviceName: ${playlistSettings.deviceName || 'first-device'}` : ''}
408
+
409
+ REASONING (private scratchpad)
410
+ - Use Plan→Check→Act→Reflect for each step.
411
+ - Default to a single deterministic path.
412
+ - Only branch in two cases:
413
+ 1) Multiple plausible feed candidates after search.
414
+ 2) Verification failure requiring targeted repair.
415
+ - When branching, keep BEAM_WIDTH=2, DEPTH_LIMIT=2.
416
+ - Score candidates by: correctness, coverage, determinism, freshness, cost.
417
+ - Keep reasoning hidden; publicly print one status sentence before each tool call.
418
+
419
+ KEY RULES
420
+ - Domains: ".eth" and ".tez" are OWNER DOMAINS. Resolve to addresses before querying ownership.
421
+ - Do not fabricate or truncate contract addresses or tokenIds.
422
+ - Title/slug: when calling build_playlist, pass actual null (not string "null"):
423
+ • If title provided in settings → pass settings.title as-is
424
+ • If title NOT provided → pass null (NOT the string "null")
425
+ • If slug provided in settings → pass settings.slug as-is
426
+ • If slug NOT provided → pass null (NOT the string "null")
427
+ - Shuffle: set shuffle = ${playlistSettings.preserveOrder === false ? 'true' : 'false'}.
428
+ - Build → Verify${hasDevice ? ' → Send' : ''} (MANDATORY to verify before${hasDevice ? ' sending' : ' finishing'}).
429
+
430
+ DECISION LOOP
431
+ 1) For each requirement in order:
432
+ - build_playlist: call query_requirement(requirement, duration=${playlistSettings.durationPerItem || 10}).
433
+ Returns array with minimal item data including id field. Collect the id values.
434
+ - query_address:
435
+ • if ownerAddress endsWith .eth/.tez → resolve_domains([domain]); if resolved → use returned address; if not → mark failed and continue.
436
+ • if ownerAddress is 0x…/tz… → call query_requirement(requirement, duration=${playlistSettings.durationPerItem || 10}).
437
+ - fetch_feed: search_feed_playlist(name) → take bestMatch → fetch_feed_playlist_items(bestMatch, quantity, duration=${playlistSettings.durationPerItem || 10}).
438
+ - Collect item IDs across all steps in an array (let's call it collectedItemIds).
439
+ 2) If zero items → explain briefly and finish.
440
+ 3) If some requirements failed and interactive mode → ask user; otherwise proceed with available items.
441
+ 4) Call build_playlist({ itemIds: collectedItemIds, title: settings.title || null, slug: settings.slug || null, shuffle }).
442
+ CRITICAL:
443
+ - Pass itemIds array containing the id field from each item
444
+ - Pass actual null values for title/slug, NOT the string "null"
445
+ - Returns: { playlistId, itemCount, title, dpVersion, hasSigned, slug }
446
+ - Store the playlistId in a variable.
447
+ 5) Call verify_playlist({ playlistId: <the_playlistId_from_step_4> }).
448
+ CRITICAL: Pass the playlistId string, not an object.
449
+ Returns: { valid: true/false, playlistId, itemCount } or { valid: false, error, details }
450
+ If invalid ≤3 attempts, analyze error.details and rebuild; otherwise stop with clear error.
451
+ ${sendStep}
452
+
453
+ KEY RULES
454
+ - NEVER pass full item objects or playlist objects to functions
455
+ - ALWAYS use item IDs (strings) and playlist IDs (strings)
456
+ - The registry system handles full objects internally
457
+
458
+ OUTPUT RULES
459
+ - Before each function call, print exactly one sentence: "→ I'm …" describing the action.
460
+ - Then call exactly one function with JSON arguments.
461
+ - No chain‑of‑thought or extra narration; keep public output minimal.
462
+
463
+ STOPPING CONDITIONS
464
+ - Finish only after: (items built → playlist built → verified${hasDevice ? ' → sent' : ''}) or after explaining why no progress is possible.`;
465
+ }
466
+ /**
467
+ * Build playlist using AI orchestration (natural language path)
468
+ *
469
+ * @param {Object} params - Validated parameters from intent parser
470
+ * @param {Object} options - Build options
471
+ * @param {boolean} options.interactive - Whether in interactive mode (can ask user)
472
+ * @returns {Promise<Object>} Result with playlist
473
+ */
474
+ async function buildPlaylistWithAI(params, options = {}) {
475
+ const { modelName, verbose = false, outputPath = 'playlist.json', interactive = false, conversationContext = null, } = options;
476
+ const OpenAI = require('openai');
477
+ const { getModelConfig } = require('../config');
478
+ const modelConfig = getModelConfig(modelName);
479
+ const client = new OpenAI({
480
+ apiKey: modelConfig.apiKey,
481
+ baseURL: modelConfig.baseURL,
482
+ timeout: modelConfig.timeout,
483
+ maxRetries: modelConfig.maxRetries,
484
+ });
485
+ let messages;
486
+ if (conversationContext && conversationContext.messages) {
487
+ // Continue from existing conversation
488
+ messages = [...conversationContext.messages];
489
+ messages.push({
490
+ role: 'user',
491
+ content: conversationContext.userResponse,
492
+ });
493
+ }
494
+ else {
495
+ // Start new conversation
496
+ const systemPrompt = buildOrchestratorSystemPrompt(params);
497
+ const interactiveNote = interactive
498
+ ? '\n\nYou are in INTERACTIVE MODE. You can ask the user for confirmation when some requirements fail.'
499
+ : '\n\nYou are in NON-INTERACTIVE MODE. If some requirements fail, automatically proceed with available items without asking.';
500
+ // Build detailed user message with the actual requirements
501
+ const requirementsDetail = params.requirements
502
+ .map((req, i) => {
503
+ if (req.type === 'fetch_feed') {
504
+ return `${i + 1}. Fetch ${req.quantity || 5} items from playlist "${req.playlistName}"`;
505
+ }
506
+ else if (req.type === 'query_address') {
507
+ const quantityDesc = req.quantity === 'all'
508
+ ? 'all tokens (with pagination)'
509
+ : req.quantity
510
+ ? `${req.quantity} (random selection)`
511
+ : 'all tokens';
512
+ return `${i + 1}. Query tokens from address:\n - ownerAddress: "${req.ownerAddress}"\n - quantity: ${quantityDesc}`;
513
+ }
514
+ else {
515
+ return `${i + 1}. Query tokens:\n - blockchain: "${req.blockchain}"\n - contractAddress: "${req.contractAddress}"\n - tokenIds: ${JSON.stringify(req.tokenIds)}\n - quantity: ${req.quantity}`;
516
+ }
517
+ })
518
+ .join('\n');
519
+ messages = [
520
+ { role: 'system', content: systemPrompt + interactiveNote },
521
+ {
522
+ role: 'user',
523
+ content: `Execute these requirements now. Use the EXACT values provided - do not modify or make up different values:\n\n${requirementsDetail}\n\nStart by calling query_requirement for each requirement with these EXACT values.`,
524
+ },
525
+ ];
526
+ }
527
+ let finalPlaylist = null;
528
+ let iterationCount = 0;
529
+ let collectedItems = [];
530
+ let verificationFailures = 0;
531
+ let sentToDevice = false;
532
+ const maxIterations = 20;
533
+ const maxVerificationRetries = 3;
534
+ while (iterationCount < maxIterations) {
535
+ iterationCount++;
536
+ const requestParams = {
537
+ model: modelConfig.model,
538
+ messages,
539
+ tools: functionSchemas,
540
+ tool_choice: 'auto',
541
+ stream: false,
542
+ };
543
+ if (modelConfig.temperature !== undefined) {
544
+ requestParams.temperature = modelConfig.temperature;
545
+ }
546
+ if (modelConfig.model.startsWith('gpt-')) {
547
+ requestParams.max_completion_tokens = 4000;
548
+ }
549
+ else {
550
+ requestParams.max_tokens = 4000;
551
+ }
552
+ let response;
553
+ try {
554
+ response = await createCompletionWithRetry(client, requestParams, modelConfig.maxRetries);
555
+ }
556
+ catch (error) {
557
+ const status = error?.response?.status ?? error?.status;
558
+ const statusText = error?.response?.statusText;
559
+ const responseDetails = error?.response?.data && typeof error.response.data === 'string'
560
+ ? error.response.data
561
+ : error?.response?.data
562
+ ? JSON.stringify(error.response.data)
563
+ : null;
564
+ const detailParts = [
565
+ error.message,
566
+ status ? `status ${status}${statusText ? ` ${statusText}` : ''}` : null,
567
+ responseDetails ? `response ${responseDetails}` : null,
568
+ ].filter(Boolean);
569
+ const hint = status === 429 ? 'rate limited by model provider' : null;
570
+ throw new Error(`AI orchestrator failed (model=${modelConfig.model}, baseURL=${modelConfig.baseURL}): ${detailParts.join(' | ')}${hint ? ` | ${hint}` : ''}`);
571
+ }
572
+ const message = response.choices[0].message;
573
+ // Gemini workaround: If AI finished without calling build_playlist despite having items
574
+ // This handles cases where:
575
+ // - finish_reason is 'stop' but no content/tool_calls
576
+ // - finish_reason includes 'MALFORMED_FUNCTION_CALL' (Gemini tried but failed)
577
+ // - Any other case where we have items but no playlist
578
+ if (verbose) {
579
+ console.log(chalk.gray(`→ finish_reason: ${response.choices[0].finish_reason}`));
580
+ console.log(chalk.gray(`→ has content: ${!!message.content}`));
581
+ console.log(chalk.gray(`→ has tool_calls: ${!!message.tool_calls}`));
582
+ console.log(chalk.gray(`→ collectedItems: ${collectedItems.length}, finalPlaylist: ${!!finalPlaylist}`));
583
+ }
584
+ if (!message.tool_calls && collectedItems.length > 0 && !finalPlaylist) {
585
+ const finishReason = response.choices[0].finish_reason || '';
586
+ // If Gemini keeps failing with MALFORMED_FUNCTION_CALL, call build_playlist directly
587
+ if (finishReason.includes('MALFORMED_FUNCTION_CALL') || finishReason.includes('filter')) {
588
+ if (verbose) {
589
+ console.log(chalk.yellow(`⚠️ AI's function call is malformed - calling build_playlist directly...`));
590
+ }
591
+ // Call build_playlist directly with the collected item IDs
592
+ try {
593
+ const utilities = require('../utilities');
594
+ // Retrieve full items from registry using IDs
595
+ const fullItems = collectedItems
596
+ .map((id) => registry.getItem(id))
597
+ .filter((item) => item !== undefined);
598
+ if (fullItems.length > 0) {
599
+ const result = await utilities.buildDP1Playlist(fullItems, params.playlistSettings?.title || null, params.playlistSettings?.slug || null);
600
+ if (result.dpVersion) {
601
+ finalPlaylist = result;
602
+ const { savePlaylist } = require('../utils');
603
+ await savePlaylist(result, outputPath);
604
+ if (verbose) {
605
+ console.log(chalk.green(`✓ Successfully built playlist directly`));
606
+ }
607
+ break; // Exit the loop
608
+ }
609
+ }
610
+ }
611
+ catch (error) {
612
+ if (verbose) {
613
+ console.log(chalk.red(`✗ Failed to build playlist directly: ${error.message}`));
614
+ }
615
+ }
616
+ }
617
+ else if (iterationCount < maxIterations - 1) {
618
+ // Try one more time with a system message
619
+ if (verbose) {
620
+ console.log(chalk.yellow(`⚠️ AI stopped without calling build_playlist (reason: ${finishReason}) - forcing it to continue...`));
621
+ }
622
+ messages.push({
623
+ role: 'system',
624
+ content: `CRITICAL: You have collected ${collectedItems.length} items but have NOT called build_playlist yet. You MUST call the build_playlist function NOW with these items.`,
625
+ });
626
+ continue; // Go to next iteration
627
+ }
628
+ }
629
+ messages.push(message);
630
+ // Always print AI content when present
631
+ if (message.content) {
632
+ console.log(chalk.cyan(message.content));
633
+ }
634
+ if (verbose) {
635
+ console.log(chalk.gray(`\nIteration ${iterationCount}:`));
636
+ }
637
+ // Execute function calls if any
638
+ if (message.tool_calls && message.tool_calls.length > 0) {
639
+ if (verbose) {
640
+ console.log(chalk.gray(`→ Executing ${message.tool_calls.length} function(s)...`));
641
+ }
642
+ for (const toolCall of message.tool_calls) {
643
+ const functionName = toolCall.function.name;
644
+ const args = JSON.parse(toolCall.function.arguments);
645
+ if (verbose) {
646
+ console.log(chalk.gray(`\n • Function: ${chalk.bold(functionName)}`));
647
+ console.log(chalk.gray(` Input: ${JSON.stringify(args, null, 2).split('\n').join('\n ')}`));
648
+ }
649
+ try {
650
+ const result = await executeFunction(functionName, args);
651
+ if (verbose) {
652
+ console.log(chalk.gray(` Output: ${JSON.stringify(result, null, 2).split('\n').join('\n ')}`));
653
+ }
654
+ // Track collected item IDs from query_requirement
655
+ if (functionName === 'query_requirement' && Array.isArray(result)) {
656
+ // Result now contains minimal item objects with id field
657
+ const itemIds = result.map((item) => item.id).filter((id) => id);
658
+ collectedItems = collectedItems.concat(itemIds); // Now storing IDs, not full items
659
+ if (verbose) {
660
+ console.log(chalk.green(` ✓ Collected ${result.length} item IDs (total: ${collectedItems.length})`));
661
+ }
662
+ }
663
+ // Track final playlist by retrieving it from registry
664
+ if (functionName === 'build_playlist' && result.playlistId) {
665
+ // Retrieve full playlist from registry
666
+ finalPlaylist = registry.getPlaylist(result.playlistId);
667
+ // Save playlist
668
+ const { savePlaylist } = require('../utils');
669
+ await savePlaylist(finalPlaylist, outputPath);
670
+ }
671
+ // Track device sending
672
+ if (functionName === 'send_to_device') {
673
+ if (result && result.success) {
674
+ sentToDevice = true;
675
+ if (verbose) {
676
+ console.log(chalk.green(`✓ Playlist sent to device`));
677
+ }
678
+ }
679
+ }
680
+ // Handle verification results
681
+ if (functionName === 'verify_playlist') {
682
+ if (result.valid) {
683
+ if (verbose) {
684
+ console.log(chalk.green(`✓ Playlist verification passed`));
685
+ }
686
+ // Check if verification passed - don't break yet, let AI continue (may need to call send_to_device)
687
+ // The loop will naturally end when AI has no more tool calls or we hit iteration limit
688
+ // if (verificationPassed) {
689
+ // break;
690
+ // }
691
+ }
692
+ else {
693
+ verificationFailures++;
694
+ if (verbose) {
695
+ console.log(chalk.yellow(`⚠️ Playlist verification failed (attempt ${verificationFailures}/${maxVerificationRetries})`));
696
+ }
697
+ // Check if we've exceeded max retries
698
+ if (verificationFailures >= maxVerificationRetries) {
699
+ if (verbose) {
700
+ console.log(chalk.red(`✗ Playlist validation failed after ${maxVerificationRetries} retries`));
701
+ }
702
+ return {
703
+ success: false,
704
+ error: `Playlist validation failed: ${result.error}`,
705
+ details: result.details,
706
+ playlist: null,
707
+ };
708
+ }
709
+ // Add verification error to messages so AI can fix it
710
+ messages.push({
711
+ role: 'tool',
712
+ tool_call_id: toolCall.id,
713
+ content: JSON.stringify({
714
+ valid: false,
715
+ error: result.error,
716
+ details: result.details,
717
+ message: `Playlist validation failed. Please fix the issues and rebuild the playlist.\n\nErrors: ${JSON.stringify(result.details, null, 2)}`,
718
+ }),
719
+ });
720
+ // Ask AI to fix and rebuild
721
+ const fixPrompt = `The playlist validation failed with these errors:\n\n${result.error}\n\nDetails:\n${result.details ? result.details.map((d) => `- ${d.path}: ${d.message}`).join('\n') : 'N/A'}\n\nPlease fix these issues and rebuild the playlist. You can rebuild it by calling build_playlist again with corrected data.`;
722
+ messages.push({
723
+ role: 'user',
724
+ content: fixPrompt,
725
+ });
726
+ continue; // Don't finish, let AI try again
727
+ }
728
+ }
729
+ messages.push({
730
+ role: 'tool',
731
+ tool_call_id: toolCall.id,
732
+ content: JSON.stringify(result),
733
+ });
734
+ }
735
+ catch (error) {
736
+ if (verbose) {
737
+ console.log(chalk.red(` Error: ${error.message}`));
738
+ }
739
+ messages.push({
740
+ role: 'tool',
741
+ tool_call_id: toolCall.id,
742
+ content: JSON.stringify({ error: error.message, success: false }),
743
+ });
744
+ }
745
+ }
746
+ // Check if verification passed - don't break yet, let AI continue (may need to call send_to_device)
747
+ // The loop will naturally end when AI has no more tool calls or we hit iteration limit
748
+ // if (verificationPassed) {
749
+ // break;
750
+ // }
751
+ }
752
+ else {
753
+ // AI has finished
754
+ if (verbose) {
755
+ console.log(chalk.gray('\n→ AI has finished (no more tool calls)'));
756
+ if (!message.content) {
757
+ console.log(chalk.red('→ AI sent NO content and NO tool calls!'));
758
+ }
759
+ }
760
+ if (finalPlaylist) {
761
+ // Deterministic fallback: if user requested device sending but AI forgot,
762
+ // send here before returning (only if NOT already sent by AI)
763
+ try {
764
+ const deviceNameRequested = params.playlistSettings && params.playlistSettings.deviceName !== undefined;
765
+ if (deviceNameRequested && !sentToDevice) {
766
+ console.log(chalk.cyan('\n→ Sending to device...'));
767
+ const utilities = require('../utilities');
768
+ const sendResult = await utilities.sendToDevice(finalPlaylist, params.playlistSettings.deviceName || null);
769
+ if (sendResult && sendResult.success) {
770
+ sentToDevice = true;
771
+ console.log(chalk.green(`✓ Sent to device: ${sendResult.deviceName}`));
772
+ }
773
+ else {
774
+ // Device sending failed - this is a failure condition
775
+ return {
776
+ success: false,
777
+ error: `Failed to send playlist to device: ${sendResult?.error || 'Unknown error'}`,
778
+ playlist: finalPlaylist,
779
+ sentToDevice: false,
780
+ };
781
+ }
782
+ }
783
+ }
784
+ catch (error) {
785
+ return {
786
+ success: false,
787
+ error: `Failed to send to device: ${error && error.message ? error.message : error}`,
788
+ playlist: finalPlaylist,
789
+ sentToDevice: false,
790
+ };
791
+ }
792
+ // Publish to feed server if requested
793
+ let publishResult = null;
794
+ if (params.playlistSettings && params.playlistSettings.feedServer) {
795
+ console.log(chalk.cyan('\n→ Publishing to feed server...'));
796
+ try {
797
+ const { publishPlaylist } = require('../utilities/playlist-publisher');
798
+ publishResult = await publishPlaylist(outputPath, params.playlistSettings.feedServer.baseUrl, params.playlistSettings.feedServer.apiKey);
799
+ if (publishResult.success) {
800
+ console.log(chalk.green(`✓ Published to feed server`));
801
+ if (publishResult.playlistId) {
802
+ console.log(chalk.gray(` Playlist ID: ${publishResult.playlistId}`));
803
+ }
804
+ if (publishResult.feedServer) {
805
+ console.log(chalk.gray(` Server: ${publishResult.feedServer}`));
806
+ }
807
+ }
808
+ else {
809
+ console.error(chalk.red(`✗ Failed to publish: ${publishResult.error}`));
810
+ if (publishResult.message) {
811
+ console.error(chalk.gray(` ${publishResult.message}`));
812
+ }
813
+ }
814
+ }
815
+ catch (error) {
816
+ console.error(chalk.red(`✗ Failed to publish: ${error.message}`));
817
+ if (verbose) {
818
+ console.error(chalk.gray(error.stack));
819
+ }
820
+ }
821
+ }
822
+ // Clear registries after successful build
823
+ registry.clearRegistries();
824
+ return {
825
+ playlist: finalPlaylist,
826
+ sentToDevice,
827
+ published: publishResult?.success || false,
828
+ publishResult,
829
+ };
830
+ }
831
+ // AI finished without building a playlist - check if it provided an explanation
832
+ if (message.content) {
833
+ // Check if AI is asking for confirmation (interactive mode)
834
+ const isAskingConfirmation = interactive &&
835
+ message.content.toLowerCase().includes('would you like') &&
836
+ (message.content.toLowerCase().includes('proceed') ||
837
+ message.content.toLowerCase().includes('build') ||
838
+ message.content.toLowerCase().includes('cancel'));
839
+ if (isAskingConfirmation) {
840
+ // Return with needsConfirmation flag
841
+ return {
842
+ needsConfirmation: true,
843
+ question: message.content,
844
+ messages: messages,
845
+ params: params,
846
+ };
847
+ }
848
+ // AI has explained why no playlist was built (e.g., no matching items found)
849
+ // Content already printed above, just return the result
850
+ return {
851
+ success: false,
852
+ message: message.content,
853
+ playlist: null,
854
+ };
855
+ }
856
+ break;
857
+ }
858
+ }
859
+ if (!finalPlaylist) {
860
+ registry.clearRegistries(); // Clear on failure
861
+ throw new Error('Failed to build playlist - No items found or AI did not complete the task. Check if the requirements match any available data.');
862
+ }
863
+ return { playlist: finalPlaylist, sentToDevice };
864
+ }
865
+ module.exports = {
866
+ functionSchemas,
867
+ executeFunction,
868
+ buildOrchestratorSystemPrompt,
869
+ buildPlaylistWithAI,
870
+ };