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