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,1342 @@
1
+ "use strict";
2
+ /**
3
+ * Intent Parser
4
+ * Parses user intent and breaks down into structured requirements.
5
+ * Each requirement specifies: blockchain, contract address, token ID, source (media URL).
6
+ */
7
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
+ if (k2 === undefined) k2 = k;
9
+ var desc = Object.getOwnPropertyDescriptor(m, k);
10
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
+ desc = { enumerable: true, get: function() { return m[k]; } };
12
+ }
13
+ Object.defineProperty(o, k2, desc);
14
+ }) : (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ o[k2] = m[k];
17
+ }));
18
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
20
+ }) : function(o, v) {
21
+ o["default"] = v;
22
+ });
23
+ var __importStar = (this && this.__importStar) || (function () {
24
+ var ownKeys = function(o) {
25
+ ownKeys = Object.getOwnPropertyNames || function (o) {
26
+ var ar = [];
27
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
+ return ar;
29
+ };
30
+ return ownKeys(o);
31
+ };
32
+ return function (mod) {
33
+ if (mod && mod.__esModule) return mod;
34
+ var result = {};
35
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
+ __setModuleDefault(result, mod);
37
+ return result;
38
+ };
39
+ })();
40
+ var __importDefault = (this && this.__importDefault) || function (mod) {
41
+ return (mod && mod.__esModule) ? mod : { "default": mod };
42
+ };
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.intentParserFunctionSchemas = void 0;
45
+ exports.processIntentParserRequest = processIntentParserRequest;
46
+ exports.buildIntentParserSystemPrompt = buildIntentParserSystemPrompt;
47
+ const openai_1 = __importDefault(require("openai"));
48
+ const chalk_1 = __importDefault(require("chalk"));
49
+ const config_1 = require("../config");
50
+ const utils_1 = require("./utils");
51
+ // Cache for AI clients
52
+ const clientCache = new Map();
53
+ /**
54
+ * Create AI client for intent parser
55
+ *
56
+ * @param {string} [modelName] - Model name
57
+ * @returns {OpenAI} OpenAI client
58
+ */
59
+ function createIntentParserClient(modelName) {
60
+ const config = (0, config_1.getConfig)();
61
+ const selectedModel = modelName || config.defaultModel;
62
+ if (clientCache.has(selectedModel)) {
63
+ return clientCache.get(selectedModel);
64
+ }
65
+ const modelConfig = (0, config_1.getModelConfig)(selectedModel);
66
+ const client = new openai_1.default({
67
+ apiKey: modelConfig.apiKey,
68
+ baseURL: modelConfig.baseURL,
69
+ timeout: modelConfig.timeout,
70
+ maxRetries: modelConfig.maxRetries,
71
+ });
72
+ clientCache.set(selectedModel, client);
73
+ return client;
74
+ }
75
+ /**
76
+ * Build intent parser system prompt
77
+ *
78
+ * @returns {string} System prompt for intent parser
79
+ */
80
+ function buildIntentParserSystemPrompt() {
81
+ const deviceConfig = (0, config_1.getFF1DeviceConfig)();
82
+ const hasDevices = deviceConfig.devices && deviceConfig.devices.length > 0;
83
+ let deviceInfo = '';
84
+ if (hasDevices) {
85
+ const deviceList = deviceConfig.devices
86
+ .map((d, i) => ` ${i + 1}. ${d.name || d.host}`)
87
+ .filter((line) => !line.includes('undefined'))
88
+ .join('\n');
89
+ if (deviceList) {
90
+ deviceInfo = `\n\nAVAILABLE FF1 DEVICES:\n${deviceList}`;
91
+ }
92
+ }
93
+ return `SYSTEM: FF1 Intent Parser
94
+
95
+ ROLE
96
+ - Turn user text into deterministic parameters for non‑AI execution. Keep public output minimal and structured.
97
+
98
+ REASONING (private scratchpad)
99
+ - Use Plan→Check→Act→Reflect for each step.
100
+ - Default to a single deterministic path.
101
+ - Only branch in two cases:
102
+ 1) Multiple plausible feed candidates after search.
103
+ 2) Verification failure requiring targeted repair.
104
+ - When branching, keep BEAM_WIDTH=2, DEPTH_LIMIT=2.
105
+ - Score candidates by: correctness, coverage, determinism, freshness, cost.
106
+ - Keep reasoning hidden; publicly print one status sentence before each tool call.
107
+
108
+ OUTPUT CONTRACT
109
+ - BUILD → call parse_requirements with { requirements: Requirement[], playlistSettings?: { title?: string | null, slug?: string | null, durationPerItem?: number, preserveOrder?: boolean, deviceName?: string, feedServer?: { baseUrl: string, apiKey?: string } } }
110
+ - SEND → call confirm_send_playlist with { filePath: string, deviceName?: string }
111
+ - PUBLISH (existing file) → call confirm_publish_playlist with { filePath: string, feedServer: { baseUrl: string, apiKey?: string } }
112
+ - QUESTION → answer briefly (no tool call)
113
+ - Use correct types; never truncate addresses/tokenIds; tokenIds are strings; quantity is a number.
114
+
115
+ REQUIREMENT TYPES (BUILD)
116
+ - build_playlist: { type, blockchain: "ethereum"|"tezos", contractAddress, tokenIds: string[], quantity?: number, source?: string }
117
+ • ONLY use when user explicitly provides BOTH contract address AND specific token IDs
118
+ • Example: "tokens 1, 2, 3 from contract 0x123"
119
+ - query_address: { type, ownerAddress: 0x…|tz…|domain.eth|domain.tez, quantity?: number | "all" }
120
+ • Domains (.eth/.tez) are OWNER DOMAINS. Do not ask for tokenIds. Do not treat as contracts.
121
+ • A raw 0x…/tz… without tokenIds is an OWNER ADDRESS (query_address), not a contract.
122
+ • CRITICAL: Phrases like "N items from [address]", "NFTs from [address]", "tokens from [address]" → query_address
123
+ • Example: "30 items from 0xABC" → query_address with quantity=30
124
+ • When user says "all", "all tokens", "all NFTs" → use quantity="all" (string, not number)
125
+ • quantity="all" will fetch ALL tokens using pagination, can handle thousands of tokens
126
+ - fetch_feed: { type, playlistName: string, quantity?: number (default 5) }
127
+
128
+ DOMAIN OWNER RULES (CRITICAL)
129
+ - Interpret \`*.eth\` as an Ethereum OWNER DOMAIN → produce \`query_address\` with \`ownerAddress\` set to the domain string (e.g., \`reas.eth\`).
130
+ - Interpret \`*.tez\` as a Tezos OWNER DOMAIN → produce \`query_address\` with \`ownerAddress\` set to the domain string (e.g., \`einstein-rosen.tez\`).
131
+ - Never treat \.eth or \.tez as a contract or collection identifier.
132
+ - Never invent or request \`tokenIds\` for \.eth/\.tez domains. Use \`quantity\` only.
133
+
134
+ EXAMPLES (query_address - NO tokenIds needed)
135
+ - "Pick 3 artworks from reas.eth" → \`query_address\` { ownerAddress: "reas.eth", quantity: 3 }
136
+ - "3 from einstein-rosen.tez and play on my FF1" → \`query_address\` { ownerAddress: "einstein-rosen.tez", quantity: 3 } and set \`playlistSettings.deviceName\` accordingly
137
+ - "create a playlist of 30 items from 0xABC" → \`query_address\` { ownerAddress: "0xABC", quantity: 30 }
138
+ - "get 20 NFTs from 0x123" → \`query_address\` { ownerAddress: "0x123", quantity: 20 }
139
+ - "create a playlist from all tokens in reas.eth" → \`query_address\` { ownerAddress: "reas.eth", quantity: "all" }
140
+ - "get all NFTs from 0xABC" → \`query_address\` { ownerAddress: "0xABC", quantity: "all" }
141
+
142
+ EXAMPLES (fetch_feed)
143
+ - "Pick 3 artworks from Social Codes and 2 from a2p. Mix them up." → \`fetch_feed\` { playlistName: "Social Codes", quantity: 3 } + \`fetch_feed\` { playlistName: "a2p", quantity: 2 }, and set \`playlistSettings.preserveOrder\` = false
144
+
145
+ EXAMPLES (build_playlist - requires BOTH contract AND tokenIds)
146
+ - "tokens 5, 10, 15 from contract 0xABC on ethereum" → \`build_playlist\` { blockchain: "ethereum", contractAddress: "0xABC", tokenIds: ["5", "10", "15"] }
147
+
148
+ PLAYLIST SETTINGS EXTRACTION
149
+ - durationPerItem: parse phrases (e.g., "6 seconds each" → 6)
150
+ - preserveOrder: default true; synonyms ("shuffle", "randomize", "mix", "mix them up", "scramble") → false
151
+ - title/slug: optional; include only if provided by the user
152
+ - deviceName: from phrases like "send to", "display on", "play on", "push to"${hasDevices ? '\n- available devices:\n' + deviceInfo.replace('\n\nAVAILABLE FF1 DEVICES:\n', '') : ''}
153
+
154
+ GENERIC DEVICE RESOLUTION (CRITICAL)
155
+ - When the user references a generic device like "FF1", "my FF1", "my device", "my display", or similar (without a specific name), you MUST:
156
+ 1. Immediately call get_configured_devices() to retrieve the list of devices
157
+ 2. Extract the first device's name from the returned list
158
+ 3. Use that exact device name in playlistSettings.deviceName
159
+ 4. After resolving, acknowledge the resolved device name in your bullet summary (e.g., "send to device: Living Room")
160
+ - Example: "push to my FF1" → call get_configured_devices() → use devices[0].name as deviceName → show "device: Living Room" in bullets
161
+ - Do NOT ask the user which device to use when they say generic names like "FF1" or "my device"
162
+
163
+ MISSING INFO POLICY (ASK AT MOST ONE QUESTION)
164
+ - build_playlist: ask for blockchain/contract/tokenIds if unclear
165
+ - fetch_feed: ask for playlistName if unclear
166
+ - query_address: ask for owner/domain if unclear
167
+ - send: ask for device name only if user specifies a device by name and it's ambiguous; for generic references, always use get_configured_devices
168
+
169
+ ADDRESS VALIDATION (CRITICAL)
170
+ - When user enters any Ethereum (0x...) or Tezos (tz.../KT1) addresses, IMMEDIATELY call verify_addresses() BEFORE parsing requirements
171
+ - This includes: contract addresses in build_playlist, owner addresses in query_address, or any wallet/contract address mentioned
172
+ - Example: user says "get tokens from 0xABC" → first call verify_addresses(['0xABC']) → get validation result → then parse_requirements
173
+ - If verify_addresses returns valid=false, show user the error and ask them to provide the correct address
174
+ - If valid=true, proceed to parse_requirements with the verified addresses
175
+
176
+ FREE‑FORM COLLECTION NAMES
177
+ - Treat as fetch_feed; do not guess contracts. If user says "some", default quantity = 5.
178
+
179
+ FEED NAME HEURISTICS (CRITICAL)
180
+ - If a source is named without an address or domain (no 0x… / tz… / *.eth / *.tez), interpret it as a feed playlist name and produce \`fetch_feed\` immediately.
181
+ - Prefer acting over asking: only ask when there are zero matches or multiple plausible feed candidates after search.
182
+ - Multi‑source phrasing like "X and Y" should yield multiple \`fetch_feed\` requirements, each with its own \`quantity\` when specified.
183
+ - Never convert a plain name into a contract query; keep it as \`fetch_feed\`.
184
+
185
+ SEND INTENT
186
+ - Triggers: display/push/send/cast/send to device/play on FF1
187
+ - Always call confirm_send_playlist with filePath (default "./playlist.json") and optional deviceName
188
+ - Device selection: exact match → case‑insensitive → if multiple/none → ask user to choose
189
+
190
+ PUBLISH INTENT (CRITICAL)
191
+ - Triggers: "publish", "publish to my feed", "push to feed", "send to feed", "publish to feed"
192
+ - Distinguish from FF1 device commands: "publish" = feed server, "display/send to device" = FF1 device
193
+ - TWO MODES:
194
+
195
+ MODE 1: BUILD AND PUBLISH (user includes sources/requirements)
196
+ - Example: "Get tokens from 0xabc and publish to feed"
197
+ - When user mentions publishing WITH sources/requirements:
198
+ 1. Immediately call get_feed_servers() to retrieve available feed servers
199
+ 2. If only 1 server → use it directly in playlistSettings.feedServer
200
+ 3. If 2+ servers → ask user "Which feed server?" with numbered list (e.g., "1) https://feed.feralfile.com 2) http://localhost:8787")
201
+ 4. After selection, set playlistSettings.feedServer = { baseUrl, apiKey } from selected server
202
+ 5. Acknowledge in Settings bullets (e.g., "publish to: https://feed.feralfile.com/api/v1")
203
+ - User can request both device display AND publishing (e.g., "send to FF1 and publish to feed") → set both deviceName and feedServer
204
+ - Publishing happens automatically after playlist verification passes
205
+
206
+ MODE 2: PUBLISH EXISTING FILE (user mentions "publish playlist" or "publish the playlist")
207
+ - Triggers: "publish playlist", "publish the playlist", "publish this playlist", "publish last playlist"
208
+ - Default file path: "./playlist.json" (unless user specifies a different path like "publish ./playlist-temp.json")
209
+ - When user wants to publish an existing file WITHOUT specifying sources:
210
+ 1. Immediately call get_feed_servers() to retrieve available feed servers
211
+ 2. If only 1 server → use it directly
212
+ 3. If 2+ servers → ask user "Which feed server?" with numbered list
213
+ 4. After selection, call confirm_publish_playlist with { filePath: "./playlist.json" (or user-specified path), feedServer: { baseUrl, apiKey } }
214
+ - DO NOT ask for sources/requirements in this mode—user wants to publish an already-created playlist file
215
+
216
+ COMMUNICATION STYLE
217
+ - Acknowledge briefly: "Got it." or "Understood." (one line).
218
+ - Do not repeat the user's request; do not paraphrase it.
219
+ - Bullet the extracted facts using friendly labels (no camelCase):
220
+ • What we're building (sources/collections/addresses)
221
+ • Settings (duration per item, keep order or shuffle, device, title/slug if provided)
222
+ - Prefer human units and plain words (e.g., "2 minutes per item", "send to device: Living Room").
223
+ - When device is resolved via get_configured_devices, ALWAYS show the resolved device name in Settings bullets (e.g., "send to device: Living Room").
224
+ - Use clear, direct language; no filler or corporate jargon; neutral, warm tone.
225
+ - Immediately call the function when ready. No extra narration.`;
226
+ }
227
+ /**
228
+ * Intent parser function schemas
229
+ */
230
+ const intentParserFunctionSchemas = [
231
+ {
232
+ type: 'function',
233
+ function: {
234
+ name: 'get_configured_devices',
235
+ description: 'Get the list of configured FF1 devices. Call this IMMEDIATELY when the user references a generic device name like "FF1", "my FF1", "my device", "my display", or similar. Use the first device from the returned list.',
236
+ parameters: {
237
+ type: 'object',
238
+ properties: {},
239
+ },
240
+ },
241
+ },
242
+ {
243
+ type: 'function',
244
+ function: {
245
+ name: 'get_feed_servers',
246
+ description: 'Get the list of configured feed servers for publishing playlists. Call this IMMEDIATELY when the user mentions "publish", "push to feed", "send to feed", or "publish to my feed". Return list of available feed servers.',
247
+ parameters: {
248
+ type: 'object',
249
+ properties: {},
250
+ },
251
+ },
252
+ },
253
+ {
254
+ type: 'function',
255
+ function: {
256
+ name: 'parse_requirements',
257
+ description: 'Parse validated and complete requirements. Only call this when you have all required information for each requirement: blockchain, contract address, token IDs, and source.',
258
+ parameters: {
259
+ type: 'object',
260
+ properties: {
261
+ requirements: {
262
+ type: 'array',
263
+ description: 'Array of parsed requirements. Each can be either build_playlist (specific NFTs), fetch_feed (feed playlist), or query_address (all NFTs from address)',
264
+ items: {
265
+ type: 'object',
266
+ properties: {
267
+ type: {
268
+ type: 'string',
269
+ enum: ['build_playlist', 'fetch_feed', 'query_address'],
270
+ description: 'Type of requirement: build_playlist (specific NFTs), fetch_feed (feed playlist), or query_address (all NFTs from address)',
271
+ },
272
+ blockchain: {
273
+ type: 'string',
274
+ description: 'Blockchain network (ethereum, tezos) - only for build_playlist type',
275
+ },
276
+ contractAddress: {
277
+ type: 'string',
278
+ description: 'NFT contract address - only for build_playlist type',
279
+ },
280
+ tokenIds: {
281
+ type: 'array',
282
+ description: 'Array of token IDs to fetch - only for build_playlist type',
283
+ items: {
284
+ type: 'string',
285
+ },
286
+ },
287
+ ownerAddress: {
288
+ type: 'string',
289
+ description: 'Owner wallet address (0x... for Ethereum, tz... for Tezos) - only for query_address type',
290
+ },
291
+ source: {
292
+ type: 'string',
293
+ description: 'Media URL or source identifier - optional for build_playlist type',
294
+ },
295
+ playlistName: {
296
+ type: 'string',
297
+ description: 'Playlist name in the feed (can be any playlist name) - only for fetch_feed type',
298
+ },
299
+ quantity: {
300
+ type: ['number', 'string'],
301
+ description: 'Number of items to fetch. Can be a number for specific count, or "all" to fetch all available tokens (default: 5 for fetch_feed, all for query_address unless specified)',
302
+ },
303
+ },
304
+ required: ['type'],
305
+ },
306
+ },
307
+ playlistSettings: {
308
+ type: 'object',
309
+ description: 'Playlist configuration settings',
310
+ properties: {
311
+ title: {
312
+ type: 'string',
313
+ description: 'Playlist title (null for auto-generation)',
314
+ },
315
+ slug: {
316
+ type: 'string',
317
+ description: 'Playlist slug (null for auto-generation)',
318
+ },
319
+ durationPerItem: {
320
+ type: 'number',
321
+ description: 'Duration per item in seconds (e.g., 5 for "5 seconds each")',
322
+ },
323
+ totalDuration: {
324
+ type: 'number',
325
+ description: 'Total playlist duration in seconds (optional)',
326
+ },
327
+ preserveOrder: {
328
+ type: 'boolean',
329
+ description: 'Whether to preserve source order (true) or randomize (false)',
330
+ },
331
+ deviceName: {
332
+ type: 'string',
333
+ description: 'Device name to display on (null for first device, omit if no display requested)',
334
+ },
335
+ feedServer: {
336
+ type: 'object',
337
+ description: 'Feed server for publishing playlist (omit if no publishing requested)',
338
+ properties: {
339
+ baseUrl: {
340
+ type: 'string',
341
+ description: 'Feed server base URL',
342
+ },
343
+ apiKey: {
344
+ type: 'string',
345
+ description: 'Optional API key for authentication',
346
+ },
347
+ },
348
+ required: ['baseUrl'],
349
+ },
350
+ },
351
+ },
352
+ },
353
+ required: ['requirements'],
354
+ },
355
+ },
356
+ },
357
+ {
358
+ type: 'function',
359
+ function: {
360
+ name: 'confirm_send_playlist',
361
+ description: 'Confirm the playlist file path and device name for sending. This function is called after the user mentions "send" or similar phrases.',
362
+ parameters: {
363
+ type: 'object',
364
+ properties: {
365
+ filePath: {
366
+ type: 'string',
367
+ description: 'Path to the playlist file (default: "./playlist.json")',
368
+ },
369
+ deviceName: {
370
+ type: 'string',
371
+ description: 'Name of the device to send to (omit or leave empty if no specific device)',
372
+ },
373
+ },
374
+ required: ['filePath'],
375
+ },
376
+ },
377
+ },
378
+ {
379
+ type: 'function',
380
+ function: {
381
+ name: 'confirm_publish_playlist',
382
+ description: 'Confirm the playlist file path and feed server for publishing. This function is called when the user wants to publish an existing playlist file (e.g., "publish playlist", "publish the playlist").',
383
+ parameters: {
384
+ type: 'object',
385
+ properties: {
386
+ filePath: {
387
+ type: 'string',
388
+ description: 'Path to the playlist file (default: "./playlist.json")',
389
+ },
390
+ feedServer: {
391
+ type: 'object',
392
+ description: 'Feed server configuration for publishing',
393
+ properties: {
394
+ baseUrl: {
395
+ type: 'string',
396
+ description: 'Feed server base URL',
397
+ },
398
+ apiKey: {
399
+ type: 'string',
400
+ description: 'Optional API key for authentication',
401
+ },
402
+ },
403
+ required: ['baseUrl'],
404
+ },
405
+ },
406
+ required: ['filePath', 'feedServer'],
407
+ },
408
+ },
409
+ },
410
+ {
411
+ type: 'function',
412
+ function: {
413
+ name: 'verify_addresses',
414
+ description: 'Verify and validate Ethereum (0x...) and Tezos (tz1/tz2/tz3/KT1) wallet addresses. Call this when you detect the user has entered addresses and need to validate them before proceeding to parse requirements. This helps catch format errors early.',
415
+ parameters: {
416
+ type: 'object',
417
+ properties: {
418
+ addresses: {
419
+ type: 'array',
420
+ description: 'Array of Ethereum or Tezos addresses to verify',
421
+ items: {
422
+ type: 'string',
423
+ },
424
+ },
425
+ },
426
+ required: ['addresses'],
427
+ },
428
+ },
429
+ },
430
+ ];
431
+ exports.intentParserFunctionSchemas = intentParserFunctionSchemas;
432
+ /**
433
+ * Format markdown text for terminal display
434
+ *
435
+ * @param {string} text - Markdown text
436
+ * @returns {string} Formatted text with styling
437
+ */
438
+ function formatMarkdown(text) {
439
+ if (!text) {
440
+ return '';
441
+ }
442
+ let formatted = text;
443
+ // Headings: # text, ## text, ### text, etc.
444
+ formatted = formatted.replace(/^(#{1,6})\s+(.+)$/gm, (_, hashes, content) => {
445
+ const level = hashes.length;
446
+ if (level === 1) {
447
+ return chalk_1.default.bold.underline(content);
448
+ }
449
+ else if (level === 2) {
450
+ return chalk_1.default.bold(content);
451
+ }
452
+ else {
453
+ return chalk_1.default.bold(content);
454
+ }
455
+ });
456
+ // Bold: **text** or __text__
457
+ formatted = formatted.replace(/\*\*(.+?)\*\*/g, (_, p1) => chalk_1.default.bold(p1));
458
+ formatted = formatted.replace(/__(.+?)__/g, (_, p1) => chalk_1.default.bold(p1));
459
+ // Italic: *text* or _text_
460
+ formatted = formatted.replace(/\*([^*]+)\*/g, (_, p1) => chalk_1.default.italic(p1));
461
+ formatted = formatted.replace(/(?<!\w)_([^_]+)_(?!\w)/g, (_, p1) => chalk_1.default.italic(p1));
462
+ // Inline code: `code` - light grey color
463
+ formatted = formatted.replace(/`([^`]+)`/g, (_, p1) => chalk_1.default.grey(p1));
464
+ // Links: [text](url) - show text in blue
465
+ formatted = formatted.replace(/\[([^\]]+)\]\([^)]+\)/g, (_, p1) => chalk_1.default.blue(p1));
466
+ return formatted;
467
+ }
468
+ function printMarkdownContent(content) {
469
+ if (!content) {
470
+ return;
471
+ }
472
+ const lines = content.split('\n');
473
+ for (const line of lines) {
474
+ if (line.trim()) {
475
+ console.log(formatMarkdown(line));
476
+ }
477
+ else if (line === '') {
478
+ console.log();
479
+ }
480
+ }
481
+ }
482
+ function sleep(ms) {
483
+ return new Promise((resolve) => setTimeout(resolve, ms));
484
+ }
485
+ async function processNonStreamingResponse(response) {
486
+ const message = response.choices[0]?.message;
487
+ if (!message) {
488
+ return { message: { role: 'assistant', content: null, refusal: null } };
489
+ }
490
+ if (message.content) {
491
+ printMarkdownContent(message.content);
492
+ console.log();
493
+ }
494
+ return { message };
495
+ }
496
+ async function createChatCompletion(client, requestParams, baseURL, maxRetries = 0) {
497
+ const isGoogle = Boolean(baseURL && baseURL.includes('generativelanguage.googleapis.com'));
498
+ const shouldStream = !isGoogle;
499
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
500
+ try {
501
+ if (shouldStream) {
502
+ const stream = await client.chat.completions.create({
503
+ ...requestParams,
504
+ stream: true,
505
+ });
506
+ return await processStreamingResponse(stream);
507
+ }
508
+ const response = (await client.chat.completions.create({
509
+ ...requestParams,
510
+ stream: false,
511
+ }));
512
+ return await processNonStreamingResponse(response);
513
+ }
514
+ catch (error) {
515
+ const err = error;
516
+ const status = err.response?.status ?? err.status;
517
+ if (status === 429 && attempt < maxRetries) {
518
+ const retryAfterHeader = err.response?.headers?.['retry-after'] || err.response?.headers?.['Retry-After'];
519
+ const retryAfterMs = retryAfterHeader ? Number(retryAfterHeader) * 1000 : null;
520
+ const backoffMs = Math.min(10000, 2000 * Math.pow(2, attempt));
521
+ const delayMs = retryAfterMs && !Number.isNaN(retryAfterMs) ? retryAfterMs : backoffMs;
522
+ await sleep(delayMs);
523
+ continue;
524
+ }
525
+ throw error;
526
+ }
527
+ }
528
+ throw new Error('Failed to create chat completion');
529
+ }
530
+ /**
531
+ * Process streaming response from AI
532
+ *
533
+ * @param {AsyncIterator} stream - OpenAI streaming response
534
+ * @returns {Promise<Object>} Collected message with content and tool calls
535
+ */
536
+ async function processStreamingResponse(stream) {
537
+ let contentBuffer = '';
538
+ let toolCalls = [];
539
+ const toolCallsMap = {};
540
+ let role = 'assistant';
541
+ let printedUpTo = 0;
542
+ try {
543
+ for await (const chunk of stream) {
544
+ if (process.env.DEBUG_STREAMING) {
545
+ console.log('\n[DEBUG] Chunk:', JSON.stringify(chunk, null, 2));
546
+ }
547
+ const delta = chunk.choices[0]?.delta;
548
+ if (!delta) {
549
+ continue;
550
+ }
551
+ if (delta.role) {
552
+ role = delta.role;
553
+ }
554
+ // Collect content and print line by line
555
+ if (delta.content) {
556
+ contentBuffer += delta.content;
557
+ const lastNewlineIndex = contentBuffer.lastIndexOf('\n', contentBuffer.length - 1);
558
+ if (lastNewlineIndex >= printedUpTo) {
559
+ const textToPrint = contentBuffer.substring(printedUpTo, lastNewlineIndex + 1);
560
+ const lines = textToPrint.split('\n');
561
+ for (const line of lines) {
562
+ if (line.trim()) {
563
+ const formatted = formatMarkdown(line);
564
+ console.log(formatted);
565
+ }
566
+ else if (line === '') {
567
+ console.log();
568
+ }
569
+ }
570
+ printedUpTo = lastNewlineIndex + 1;
571
+ }
572
+ }
573
+ // Collect tool calls
574
+ if (delta.tool_calls) {
575
+ for (const toolCallDelta of delta.tool_calls) {
576
+ const index = toolCallDelta.index;
577
+ if (!toolCallsMap[index]) {
578
+ toolCallsMap[index] = {
579
+ id: '',
580
+ type: 'function',
581
+ function: {
582
+ name: '',
583
+ arguments: '',
584
+ },
585
+ };
586
+ }
587
+ if (toolCallDelta.id) {
588
+ toolCallsMap[index].id = toolCallDelta.id;
589
+ }
590
+ if (toolCallDelta.function) {
591
+ if (toolCallDelta.function.name) {
592
+ toolCallsMap[index].function.name += toolCallDelta.function.name;
593
+ }
594
+ if (toolCallDelta.function.arguments) {
595
+ toolCallsMap[index].function.arguments += toolCallDelta.function.arguments;
596
+ }
597
+ }
598
+ }
599
+ }
600
+ }
601
+ }
602
+ catch (error) {
603
+ // Log streaming error but continue with what we have
604
+ if (process.env.DEBUG) {
605
+ console.error(chalk_1.default.red('\n[Streaming Error]'), error.message);
606
+ }
607
+ }
608
+ // Print remaining content
609
+ if (printedUpTo < contentBuffer.length) {
610
+ const remainingText = contentBuffer.substring(printedUpTo);
611
+ if (remainingText.trim()) {
612
+ const formatted = formatMarkdown(remainingText);
613
+ console.log(formatted);
614
+ }
615
+ }
616
+ if (contentBuffer.length > 0) {
617
+ console.log(); // Extra newline after AI response
618
+ }
619
+ // Convert toolCallsMap to array
620
+ toolCalls = Object.values(toolCallsMap).filter((tc) => tc.id);
621
+ const message = {
622
+ role: role,
623
+ content: contentBuffer.trim() || null,
624
+ refusal: null,
625
+ };
626
+ if (toolCalls.length > 0) {
627
+ message.tool_calls = toolCalls;
628
+ }
629
+ return { message };
630
+ }
631
+ /**
632
+ * Process intent parser conversation
633
+ *
634
+ * @param {string} userRequest - User's natural language request
635
+ * @param {Object} options - Options
636
+ * @param {string} [options.modelName] - Model to use
637
+ * @param {Object} [options.conversationContext] - Previous conversation context
638
+ * @returns {Promise<Object>} Intent parser result
639
+ */
640
+ async function processIntentParserRequest(userRequest, options = {}) {
641
+ const { modelName, conversationContext } = options;
642
+ const client = createIntentParserClient(modelName);
643
+ const modelConfig = (0, config_1.getModelConfig)(modelName);
644
+ const config = (0, config_1.getConfig)();
645
+ const systemMessage = buildIntentParserSystemPrompt();
646
+ const messages = [
647
+ { role: 'system', content: systemMessage },
648
+ ];
649
+ // Add conversation context if continuing
650
+ if (conversationContext && conversationContext.messages) {
651
+ messages.push(...conversationContext.messages);
652
+ }
653
+ messages.push({ role: 'user', content: userRequest });
654
+ try {
655
+ const requestParams = {
656
+ model: modelConfig.model,
657
+ messages,
658
+ tools: intentParserFunctionSchemas,
659
+ tool_choice: 'auto',
660
+ stream: true,
661
+ };
662
+ // Set temperature based on model
663
+ if (modelConfig.temperature !== undefined && modelConfig.temperature !== 1) {
664
+ requestParams.temperature = modelConfig.temperature;
665
+ }
666
+ else if (modelConfig.temperature === 1) {
667
+ requestParams.temperature = 1;
668
+ }
669
+ if (modelConfig.model.startsWith('gpt-')) {
670
+ requestParams.max_completion_tokens = 2000;
671
+ }
672
+ else {
673
+ requestParams.max_tokens = 2000;
674
+ }
675
+ const { message } = await createChatCompletion(client, requestParams, modelConfig.baseURL, modelConfig.maxRetries);
676
+ // Check if AI wants to pass parsed requirements
677
+ if (message.tool_calls && message.tool_calls.length > 0) {
678
+ const toolCall = message.tool_calls[0];
679
+ if (toolCall.function.name === 'get_configured_devices') {
680
+ // Get the list of configured devices
681
+ const { getConfiguredDevices } = await Promise.resolve().then(() => __importStar(require('../utilities/functions')));
682
+ const result = await getConfiguredDevices();
683
+ // Add tool result to messages and continue conversation
684
+ const toolResultMessage = {
685
+ role: 'tool',
686
+ tool_call_id: toolCall.id,
687
+ content: JSON.stringify(result),
688
+ };
689
+ const updatedMessages = [...messages, message, toolResultMessage];
690
+ // Continue the conversation with the device list
691
+ const followUpRequest = {
692
+ model: modelConfig.model,
693
+ messages: updatedMessages,
694
+ tools: intentParserFunctionSchemas,
695
+ tool_choice: 'auto',
696
+ stream: true,
697
+ };
698
+ if (modelConfig.temperature !== undefined && modelConfig.temperature !== 1) {
699
+ followUpRequest.temperature = modelConfig.temperature;
700
+ }
701
+ else if (modelConfig.temperature === 1) {
702
+ followUpRequest.temperature = 1;
703
+ }
704
+ if (modelConfig.model.startsWith('gpt-')) {
705
+ followUpRequest.max_completion_tokens = 2000;
706
+ }
707
+ else {
708
+ followUpRequest.max_tokens = 2000;
709
+ }
710
+ const { message: followUpMessage } = await createChatCompletion(client, followUpRequest, modelConfig.baseURL, modelConfig.maxRetries);
711
+ // Check if AI now wants to parse requirements
712
+ if (followUpMessage.tool_calls && followUpMessage.tool_calls.length > 0) {
713
+ const followUpToolCall = followUpMessage.tool_calls[0];
714
+ if (followUpToolCall.function.name === 'parse_requirements') {
715
+ const params = JSON.parse(followUpToolCall.function.arguments);
716
+ // Apply constraints and defaults
717
+ const validatedParams = (0, utils_1.applyConstraints)(params, config);
718
+ return {
719
+ approved: true,
720
+ params: validatedParams,
721
+ needsMoreInfo: false,
722
+ };
723
+ }
724
+ else if (followUpToolCall.function.name === 'get_feed_servers') {
725
+ // Get the list of configured feed servers
726
+ const feedConfig = (0, config_1.getFeedConfig)();
727
+ // Build the server list for the AI
728
+ const serverList = feedConfig.servers?.map((server) => ({
729
+ baseUrl: server.baseUrl,
730
+ apiKey: server.apiKey,
731
+ })) ||
732
+ feedConfig.baseURLs.map((url) => ({ baseUrl: url, apiKey: feedConfig.apiKey }));
733
+ // Add tool result to messages and continue conversation
734
+ const feedToolResultMessage = {
735
+ role: 'tool',
736
+ tool_call_id: followUpToolCall.id,
737
+ content: JSON.stringify({ servers: serverList }),
738
+ };
739
+ const feedUpdatedMessages = [
740
+ ...updatedMessages,
741
+ followUpMessage,
742
+ feedToolResultMessage,
743
+ ];
744
+ // Continue the conversation with the feed server list
745
+ const feedFollowUpRequest = {
746
+ model: modelConfig.model,
747
+ messages: feedUpdatedMessages,
748
+ tools: intentParserFunctionSchemas,
749
+ tool_choice: 'auto',
750
+ stream: true,
751
+ };
752
+ if (modelConfig.temperature !== undefined && modelConfig.temperature !== 1) {
753
+ feedFollowUpRequest.temperature = modelConfig.temperature;
754
+ }
755
+ else if (modelConfig.temperature === 1) {
756
+ feedFollowUpRequest.temperature = 1;
757
+ }
758
+ if (modelConfig.model.startsWith('gpt-')) {
759
+ feedFollowUpRequest.max_completion_tokens =
760
+ 2000;
761
+ }
762
+ else {
763
+ feedFollowUpRequest.max_tokens = 2000;
764
+ }
765
+ const { message: feedFollowUpMessage } = await createChatCompletion(client, feedFollowUpRequest, modelConfig.baseURL, modelConfig.maxRetries);
766
+ // Check if AI now wants to parse requirements or publish
767
+ if (feedFollowUpMessage.tool_calls && feedFollowUpMessage.tool_calls.length > 0) {
768
+ const feedToolCall = feedFollowUpMessage.tool_calls[0];
769
+ if (feedToolCall.function.name === 'parse_requirements') {
770
+ const params = JSON.parse(feedToolCall.function.arguments);
771
+ // Apply constraints and defaults
772
+ const validatedParams = (0, utils_1.applyConstraints)(params, config);
773
+ return {
774
+ approved: true,
775
+ params: validatedParams,
776
+ needsMoreInfo: false,
777
+ };
778
+ }
779
+ else if (feedToolCall.function.name === 'confirm_publish_playlist') {
780
+ const args = JSON.parse(feedToolCall.function.arguments);
781
+ const { publishPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-publisher')));
782
+ console.log();
783
+ console.log(chalk_1.default.cyan('Publishing to feed server...'));
784
+ const publishResult = await publishPlaylist(args.filePath, args.feedServer.baseUrl, args.feedServer.apiKey);
785
+ if (publishResult.success) {
786
+ console.log(chalk_1.default.green('✓ Published to feed server'));
787
+ if (publishResult.playlistId) {
788
+ console.log(chalk_1.default.gray(` Playlist ID: ${publishResult.playlistId}`));
789
+ }
790
+ if (publishResult.feedServer) {
791
+ console.log(chalk_1.default.gray(` Server: ${publishResult.feedServer}`));
792
+ }
793
+ console.log();
794
+ return {
795
+ approved: true,
796
+ params: {
797
+ action: 'publish_playlist',
798
+ filePath: args.filePath,
799
+ feedServer: args.feedServer,
800
+ playlistId: publishResult.playlistId,
801
+ success: true,
802
+ },
803
+ needsMoreInfo: false,
804
+ };
805
+ }
806
+ else {
807
+ console.error(chalk_1.default.red('✗ Failed to publish: ' + publishResult.error));
808
+ if (publishResult.message) {
809
+ console.error(chalk_1.default.gray(` ${publishResult.message}`));
810
+ }
811
+ console.log();
812
+ return {
813
+ approved: false,
814
+ needsMoreInfo: false,
815
+ params: {
816
+ action: 'publish_playlist',
817
+ success: false,
818
+ error: publishResult.error,
819
+ },
820
+ };
821
+ }
822
+ }
823
+ }
824
+ // AI might be asking a question or needs more info
825
+ return {
826
+ approved: false,
827
+ needsMoreInfo: true,
828
+ question: feedFollowUpMessage.content || undefined,
829
+ messages: [...feedUpdatedMessages, feedFollowUpMessage],
830
+ };
831
+ }
832
+ else if (followUpToolCall.function.name === 'confirm_publish_playlist') {
833
+ // Handle publish after feed server selection
834
+ const args = JSON.parse(followUpToolCall.function.arguments);
835
+ const { publishPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-publisher')));
836
+ console.log();
837
+ console.log(chalk_1.default.cyan('Publishing to feed server...'));
838
+ const publishResult = await publishPlaylist(args.filePath, args.feedServer.baseUrl, args.feedServer.apiKey);
839
+ if (publishResult.success) {
840
+ console.log(chalk_1.default.green('✓ Published to feed server'));
841
+ if (publishResult.playlistId) {
842
+ console.log(chalk_1.default.gray(` Playlist ID: ${publishResult.playlistId}`));
843
+ }
844
+ if (publishResult.feedServer) {
845
+ console.log(chalk_1.default.gray(` Server: ${publishResult.feedServer}`));
846
+ }
847
+ console.log();
848
+ return {
849
+ approved: true,
850
+ params: {
851
+ action: 'publish_playlist',
852
+ filePath: args.filePath,
853
+ feedServer: args.feedServer,
854
+ playlistId: publishResult.playlistId,
855
+ success: true,
856
+ },
857
+ needsMoreInfo: false,
858
+ };
859
+ }
860
+ else {
861
+ console.error(chalk_1.default.red('✗ Failed to publish: ' + publishResult.error));
862
+ if (publishResult.message) {
863
+ console.error(chalk_1.default.gray(` ${publishResult.message}`));
864
+ }
865
+ console.log();
866
+ return {
867
+ approved: false,
868
+ needsMoreInfo: false,
869
+ params: {
870
+ action: 'publish_playlist',
871
+ success: false,
872
+ error: publishResult.error,
873
+ },
874
+ };
875
+ }
876
+ }
877
+ else {
878
+ // Unhandled tool call - add assistant message and tool response to messages
879
+ const toolResultMessage = {
880
+ role: 'tool',
881
+ tool_call_id: followUpToolCall.id,
882
+ content: JSON.stringify({
883
+ error: `Unknown function: ${followUpToolCall.function.name}`,
884
+ }),
885
+ };
886
+ const validMessages = [...updatedMessages, followUpMessage, toolResultMessage];
887
+ // AI is still asking for more information after the error
888
+ return {
889
+ approved: false,
890
+ needsMoreInfo: true,
891
+ question: followUpMessage.content ||
892
+ `Encountered unknown function: ${followUpToolCall.function.name}`,
893
+ messages: validMessages,
894
+ };
895
+ }
896
+ }
897
+ // AI is still asking for more information
898
+ return {
899
+ approved: false,
900
+ needsMoreInfo: true,
901
+ question: followUpMessage.content || undefined,
902
+ messages: [...updatedMessages, followUpMessage],
903
+ };
904
+ }
905
+ else if (toolCall.function.name === 'get_feed_servers') {
906
+ // Get the list of configured feed servers
907
+ const feedConfig = (0, config_1.getFeedConfig)();
908
+ // Build the server list for the AI
909
+ const serverList = feedConfig.servers?.map((server) => ({
910
+ baseUrl: server.baseUrl,
911
+ apiKey: server.apiKey,
912
+ })) || feedConfig.baseURLs.map((url) => ({ baseUrl: url, apiKey: feedConfig.apiKey }));
913
+ // Add tool result to messages and continue conversation
914
+ const toolResultMessage = {
915
+ role: 'tool',
916
+ tool_call_id: toolCall.id,
917
+ content: JSON.stringify({ servers: serverList }),
918
+ };
919
+ const updatedMessages = [...messages, message, toolResultMessage];
920
+ // Continue the conversation with the feed server list
921
+ const followUpRequest = {
922
+ model: modelConfig.model,
923
+ messages: updatedMessages,
924
+ tools: intentParserFunctionSchemas,
925
+ tool_choice: 'auto',
926
+ stream: true,
927
+ };
928
+ if (modelConfig.temperature !== undefined && modelConfig.temperature !== 1) {
929
+ followUpRequest.temperature = modelConfig.temperature;
930
+ }
931
+ else if (modelConfig.temperature === 1) {
932
+ followUpRequest.temperature = 1;
933
+ }
934
+ if (modelConfig.model.startsWith('gpt-')) {
935
+ followUpRequest.max_completion_tokens = 2000;
936
+ }
937
+ else {
938
+ followUpRequest.max_tokens = 2000;
939
+ }
940
+ const { message: followUpMessage } = await createChatCompletion(client, followUpRequest, modelConfig.baseURL, modelConfig.maxRetries);
941
+ // Check if AI now wants to parse requirements or publish
942
+ if (followUpMessage.tool_calls && followUpMessage.tool_calls.length > 0) {
943
+ const followUpToolCall = followUpMessage.tool_calls[0];
944
+ if (followUpToolCall.function.name === 'parse_requirements') {
945
+ const params = JSON.parse(followUpToolCall.function.arguments);
946
+ // Apply constraints and defaults
947
+ const validatedParams = (0, utils_1.applyConstraints)(params, config);
948
+ return {
949
+ approved: true,
950
+ params: validatedParams,
951
+ needsMoreInfo: false,
952
+ };
953
+ }
954
+ else if (followUpToolCall.function.name === 'confirm_publish_playlist') {
955
+ const args = JSON.parse(followUpToolCall.function.arguments);
956
+ const { publishPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-publisher')));
957
+ console.log();
958
+ console.log(chalk_1.default.cyan('Publishing to feed server...'));
959
+ const publishResult = await publishPlaylist(args.filePath, args.feedServer.baseUrl, args.feedServer.apiKey);
960
+ if (publishResult.success) {
961
+ console.log(chalk_1.default.green('✓ Published to feed server'));
962
+ if (publishResult.playlistId) {
963
+ console.log(chalk_1.default.gray(` Playlist ID: ${publishResult.playlistId}`));
964
+ }
965
+ if (publishResult.feedServer) {
966
+ console.log(chalk_1.default.gray(` Server: ${publishResult.feedServer}`));
967
+ }
968
+ console.log();
969
+ return {
970
+ approved: true,
971
+ params: {
972
+ action: 'publish_playlist',
973
+ filePath: args.filePath,
974
+ feedServer: args.feedServer,
975
+ playlistId: publishResult.playlistId,
976
+ success: true,
977
+ },
978
+ needsMoreInfo: false,
979
+ };
980
+ }
981
+ else {
982
+ console.error(chalk_1.default.red('✗ Failed to publish: ' + publishResult.error));
983
+ if (publishResult.message) {
984
+ console.error(chalk_1.default.gray(` ${publishResult.message}`));
985
+ }
986
+ console.log();
987
+ return {
988
+ approved: false,
989
+ needsMoreInfo: false,
990
+ params: {
991
+ action: 'publish_playlist',
992
+ success: false,
993
+ error: publishResult.error,
994
+ },
995
+ };
996
+ }
997
+ }
998
+ }
999
+ // AI might be asking a question or needs more info
1000
+ return {
1001
+ approved: false,
1002
+ needsMoreInfo: true,
1003
+ question: followUpMessage.content || undefined,
1004
+ messages: [...updatedMessages, followUpMessage],
1005
+ };
1006
+ }
1007
+ else if (toolCall.function.name === 'parse_requirements') {
1008
+ const params = JSON.parse(toolCall.function.arguments);
1009
+ // Apply constraints and defaults
1010
+ const validatedParams = (0, utils_1.applyConstraints)(params, config);
1011
+ return {
1012
+ approved: true,
1013
+ params: validatedParams,
1014
+ needsMoreInfo: false,
1015
+ };
1016
+ }
1017
+ else if (toolCall.function.name === 'confirm_send_playlist') {
1018
+ const args = JSON.parse(toolCall.function.arguments);
1019
+ const { confirmPlaylistForSending } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-send')));
1020
+ // Validate and confirm the playlist
1021
+ const confirmation = await confirmPlaylistForSending(args.filePath, args.deviceName);
1022
+ if (!confirmation.success) {
1023
+ // Add tool response message to make conversation valid
1024
+ const toolResultMessage = {
1025
+ role: 'tool',
1026
+ tool_call_id: toolCall.id,
1027
+ content: JSON.stringify({
1028
+ success: false,
1029
+ error: confirmation.error,
1030
+ message: confirmation.message,
1031
+ }),
1032
+ };
1033
+ const validMessages = [...messages, message, toolResultMessage];
1034
+ // Check if this is a device selection needed case
1035
+ if (confirmation.needsDeviceSelection) {
1036
+ // Multiple devices available - ask user to choose
1037
+ console.log();
1038
+ return {
1039
+ approved: false,
1040
+ needsMoreInfo: true,
1041
+ question: confirmation.message || 'Please choose a device',
1042
+ messages: validMessages,
1043
+ };
1044
+ }
1045
+ // File not found or playlist invalid - ask user for more info
1046
+ console.log();
1047
+ return {
1048
+ approved: false,
1049
+ needsMoreInfo: true,
1050
+ question: confirmation.message || `Failed to send playlist: ${confirmation.error}`,
1051
+ messages: validMessages,
1052
+ };
1053
+ }
1054
+ // Playlist is valid - return as approved with send_playlist action
1055
+ return {
1056
+ approved: true,
1057
+ params: {
1058
+ action: 'send_playlist',
1059
+ filePath: confirmation.filePath,
1060
+ deviceName: confirmation.deviceName,
1061
+ playlist: confirmation.playlist,
1062
+ message: confirmation.message,
1063
+ },
1064
+ needsMoreInfo: false,
1065
+ };
1066
+ }
1067
+ else if (toolCall.function.name === 'confirm_publish_playlist') {
1068
+ const args = JSON.parse(toolCall.function.arguments);
1069
+ const { publishPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-publisher')));
1070
+ // Publish the playlist
1071
+ console.log();
1072
+ console.log(chalk_1.default.cyan('Publishing to feed server...'));
1073
+ const publishResult = await publishPlaylist(args.filePath, args.feedServer.baseUrl, args.feedServer.apiKey);
1074
+ if (publishResult.success) {
1075
+ console.log(chalk_1.default.green('✓ Published to feed server'));
1076
+ if (publishResult.playlistId) {
1077
+ console.log(chalk_1.default.gray(` Playlist ID: ${publishResult.playlistId}`));
1078
+ }
1079
+ if (publishResult.feedServer) {
1080
+ console.log(chalk_1.default.gray(` Server: ${publishResult.feedServer}`));
1081
+ }
1082
+ console.log();
1083
+ return {
1084
+ approved: true,
1085
+ params: {
1086
+ action: 'publish_playlist',
1087
+ filePath: args.filePath,
1088
+ feedServer: args.feedServer,
1089
+ playlistId: publishResult.playlistId,
1090
+ success: true,
1091
+ },
1092
+ needsMoreInfo: false,
1093
+ };
1094
+ }
1095
+ else {
1096
+ console.error(chalk_1.default.red('✗ Failed to publish: ' + publishResult.error));
1097
+ if (publishResult.message) {
1098
+ console.error(chalk_1.default.gray(` ${publishResult.message}`));
1099
+ }
1100
+ console.log();
1101
+ return {
1102
+ approved: false,
1103
+ needsMoreInfo: false,
1104
+ params: {
1105
+ action: 'publish_playlist',
1106
+ success: false,
1107
+ error: publishResult.error,
1108
+ },
1109
+ };
1110
+ }
1111
+ }
1112
+ else if (toolCall.function.name === 'verify_addresses') {
1113
+ const args = JSON.parse(toolCall.function.arguments);
1114
+ const { verifyAddresses } = await Promise.resolve().then(() => __importStar(require('../utilities/functions')));
1115
+ const verificationResult = await verifyAddresses({ addresses: args.addresses });
1116
+ if (!verificationResult.valid) {
1117
+ // Add tool response message for invalid addresses
1118
+ const toolResultMessage = {
1119
+ role: 'tool',
1120
+ tool_call_id: toolCall.id,
1121
+ content: JSON.stringify({
1122
+ valid: false,
1123
+ errors: verificationResult.errors,
1124
+ results: verificationResult.results,
1125
+ }),
1126
+ };
1127
+ const validMessages = [...messages, message, toolResultMessage];
1128
+ // Ask user to correct the addresses
1129
+ return {
1130
+ approved: false,
1131
+ needsMoreInfo: true,
1132
+ question: `Some addresses are invalid. ${verificationResult.errors.join(' ')} Please provide correct addresses.`,
1133
+ messages: validMessages,
1134
+ };
1135
+ }
1136
+ // All addresses are valid - continue to next step
1137
+ const toolResultMessage = {
1138
+ role: 'tool',
1139
+ tool_call_id: toolCall.id,
1140
+ content: JSON.stringify({
1141
+ valid: true,
1142
+ results: verificationResult.results,
1143
+ }),
1144
+ };
1145
+ const validMessages = [...messages, message, toolResultMessage];
1146
+ // Continue conversation after validation
1147
+ const followUpRequest = {
1148
+ model: modelConfig.model,
1149
+ messages: validMessages,
1150
+ tools: intentParserFunctionSchemas,
1151
+ tool_choice: 'auto',
1152
+ stream: true,
1153
+ };
1154
+ if (modelConfig.temperature !== undefined && modelConfig.temperature !== 1) {
1155
+ followUpRequest.temperature = modelConfig.temperature;
1156
+ }
1157
+ else if (modelConfig.temperature === 1) {
1158
+ followUpRequest.temperature = 1;
1159
+ }
1160
+ if (modelConfig.model.startsWith('gpt-')) {
1161
+ followUpRequest.max_completion_tokens = 2000;
1162
+ }
1163
+ else {
1164
+ followUpRequest.max_tokens = 2000;
1165
+ }
1166
+ const { message: followUpMessage } = await createChatCompletion(client, followUpRequest, modelConfig.baseURL, modelConfig.maxRetries);
1167
+ // Check if AI now wants to parse requirements or get feed servers
1168
+ if (followUpMessage.tool_calls && followUpMessage.tool_calls.length > 0) {
1169
+ const followUpToolCall = followUpMessage.tool_calls[0];
1170
+ if (followUpToolCall.function.name === 'parse_requirements') {
1171
+ const params = JSON.parse(followUpToolCall.function.arguments);
1172
+ // Apply constraints and defaults
1173
+ const validatedParams = (0, utils_1.applyConstraints)(params, config);
1174
+ return {
1175
+ approved: true,
1176
+ params: validatedParams,
1177
+ needsMoreInfo: false,
1178
+ };
1179
+ }
1180
+ else if (followUpToolCall.function.name === 'get_feed_servers') {
1181
+ // Get the list of configured feed servers
1182
+ const feedConfig = (0, config_1.getFeedConfig)();
1183
+ // Build the server list for the AI
1184
+ const serverList = feedConfig.servers?.map((server) => ({
1185
+ baseUrl: server.baseUrl,
1186
+ apiKey: server.apiKey,
1187
+ })) ||
1188
+ feedConfig.baseURLs.map((url) => ({ baseUrl: url, apiKey: feedConfig.apiKey }));
1189
+ // Add tool result to messages and continue conversation
1190
+ const feedToolResultMessage = {
1191
+ role: 'tool',
1192
+ tool_call_id: followUpToolCall.id,
1193
+ content: JSON.stringify({ servers: serverList }),
1194
+ };
1195
+ const feedUpdatedMessages = [...validMessages, followUpMessage, feedToolResultMessage];
1196
+ // Continue the conversation with the feed server list
1197
+ const feedFollowUpRequest = {
1198
+ model: modelConfig.model,
1199
+ messages: feedUpdatedMessages,
1200
+ tools: intentParserFunctionSchemas,
1201
+ tool_choice: 'auto',
1202
+ stream: true,
1203
+ };
1204
+ if (modelConfig.temperature !== undefined && modelConfig.temperature !== 1) {
1205
+ feedFollowUpRequest.temperature = modelConfig.temperature;
1206
+ }
1207
+ else if (modelConfig.temperature === 1) {
1208
+ feedFollowUpRequest.temperature = 1;
1209
+ }
1210
+ if (modelConfig.model.startsWith('gpt-')) {
1211
+ feedFollowUpRequest.max_completion_tokens =
1212
+ 2000;
1213
+ }
1214
+ else {
1215
+ feedFollowUpRequest.max_tokens = 2000;
1216
+ }
1217
+ const { message: feedFollowUpMessage } = await createChatCompletion(client, feedFollowUpRequest, modelConfig.baseURL, modelConfig.maxRetries);
1218
+ // Check if AI now wants to parse requirements or publish
1219
+ if (feedFollowUpMessage.tool_calls && feedFollowUpMessage.tool_calls.length > 0) {
1220
+ const feedToolCall = feedFollowUpMessage.tool_calls[0];
1221
+ if (feedToolCall.function.name === 'parse_requirements') {
1222
+ const params = JSON.parse(feedToolCall.function.arguments);
1223
+ // Apply constraints and defaults
1224
+ const validatedParams = (0, utils_1.applyConstraints)(params, config);
1225
+ return {
1226
+ approved: true,
1227
+ params: validatedParams,
1228
+ needsMoreInfo: false,
1229
+ };
1230
+ }
1231
+ else if (feedToolCall.function.name === 'confirm_publish_playlist') {
1232
+ const args = JSON.parse(feedToolCall.function.arguments);
1233
+ const { publishPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-publisher')));
1234
+ console.log();
1235
+ console.log(chalk_1.default.cyan('Publishing to feed server...'));
1236
+ const publishResult = await publishPlaylist(args.filePath, args.feedServer.baseUrl, args.feedServer.apiKey);
1237
+ if (publishResult.success) {
1238
+ console.log(chalk_1.default.green('✓ Published to feed server'));
1239
+ if (publishResult.playlistId) {
1240
+ console.log(chalk_1.default.gray(` Playlist ID: ${publishResult.playlistId}`));
1241
+ }
1242
+ if (publishResult.feedServer) {
1243
+ console.log(chalk_1.default.gray(` Server: ${publishResult.feedServer}`));
1244
+ }
1245
+ console.log();
1246
+ return {
1247
+ approved: true,
1248
+ params: {
1249
+ action: 'publish_playlist',
1250
+ filePath: args.filePath,
1251
+ feedServer: args.feedServer,
1252
+ playlistId: publishResult.playlistId,
1253
+ success: true,
1254
+ },
1255
+ needsMoreInfo: false,
1256
+ };
1257
+ }
1258
+ else {
1259
+ console.error(chalk_1.default.red('✗ Failed to publish: ' + publishResult.error));
1260
+ if (publishResult.message) {
1261
+ console.error(chalk_1.default.gray(` ${publishResult.message}`));
1262
+ }
1263
+ console.log();
1264
+ return {
1265
+ approved: false,
1266
+ needsMoreInfo: false,
1267
+ params: {
1268
+ action: 'publish_playlist',
1269
+ success: false,
1270
+ error: publishResult.error,
1271
+ },
1272
+ };
1273
+ }
1274
+ }
1275
+ }
1276
+ // AI might be asking a question or needs more info
1277
+ return {
1278
+ approved: false,
1279
+ needsMoreInfo: true,
1280
+ question: feedFollowUpMessage.content || undefined,
1281
+ messages: [...feedUpdatedMessages, feedFollowUpMessage],
1282
+ };
1283
+ }
1284
+ }
1285
+ // AI might be asking a question or needs more info
1286
+ if (followUpMessage.content) {
1287
+ return {
1288
+ approved: false,
1289
+ needsMoreInfo: true,
1290
+ question: followUpMessage.content,
1291
+ messages: [...validMessages, followUpMessage],
1292
+ };
1293
+ }
1294
+ return {
1295
+ approved: false,
1296
+ needsMoreInfo: false,
1297
+ };
1298
+ }
1299
+ else {
1300
+ // Unhandled tool call at top level
1301
+ const toolResultMessage = {
1302
+ role: 'tool',
1303
+ tool_call_id: toolCall.id,
1304
+ content: JSON.stringify({
1305
+ error: `Unknown function: ${toolCall.function.name}`,
1306
+ }),
1307
+ };
1308
+ const validMessages = [...messages, message, toolResultMessage];
1309
+ return {
1310
+ approved: false,
1311
+ needsMoreInfo: true,
1312
+ question: message.content || `Encountered unknown function: ${toolCall.function.name}`,
1313
+ messages: validMessages,
1314
+ };
1315
+ }
1316
+ }
1317
+ // AI is asking for more information
1318
+ return {
1319
+ approved: false,
1320
+ needsMoreInfo: true,
1321
+ question: message.content || undefined,
1322
+ messages: [...messages, message],
1323
+ };
1324
+ }
1325
+ catch (error) {
1326
+ const err = error;
1327
+ const status = err.response?.status ?? err.status;
1328
+ const statusText = err.response?.statusText;
1329
+ const responseDetails = err.response?.data && typeof err.response.data === 'string'
1330
+ ? err.response.data
1331
+ : err.response?.data
1332
+ ? JSON.stringify(err.response.data)
1333
+ : null;
1334
+ const context = `model=${modelConfig.model}, baseURL=${modelConfig.baseURL}`;
1335
+ const detailParts = [
1336
+ err.message,
1337
+ status ? `status ${status}${statusText ? ` ${statusText}` : ''}` : null,
1338
+ responseDetails ? `response ${responseDetails}` : null,
1339
+ ].filter(Boolean);
1340
+ throw new Error(`Intent parser failed (${context}): ${detailParts.join(' | ')}`);
1341
+ }
1342
+ }