@feralfile/cli 1.1.1

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