@feralfile/cli 1.6.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -11
- package/config.json.example +0 -65
- package/dist/index.js +1 -3
- package/dist/package.json +3 -4
- package/dist/src/commands/config.js +15 -13
- package/dist/src/commands/play.js +55 -0
- package/dist/src/commands/setup.js +1 -73
- package/dist/src/commands/status.js +0 -13
- package/dist/src/config.js +3 -126
- package/dist/src/main.js +2 -361
- package/dist/src/utilities/ff1-device.js +55 -0
- package/dist/src/utilities/playlist-builder.js +59 -16
- package/docs/CONFIGURATION.md +0 -34
- package/docs/EXAMPLES.md +206 -215
- package/docs/PROJECT_SPEC.md +20 -18
- package/docs/README.md +51 -104
- package/package.json +3 -4
- package/dist/src/ai-orchestrator/index.js +0 -1040
- package/dist/src/ai-orchestrator/registry.js +0 -96
- package/dist/src/commands/chat.js +0 -189
- package/dist/src/intent-parser/index.js +0 -1393
- package/dist/src/intent-parser/utils.js +0 -116
- package/docs/FUNCTION_CALLING.md +0 -100
package/dist/src/main.js
CHANGED
|
@@ -1,54 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* Main Flow Controller
|
|
4
|
-
*
|
|
4
|
+
* Builds DP-1 playlists deterministically from structured parameters.
|
|
5
5
|
*/
|
|
6
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
7
|
-
if (k2 === undefined) k2 = k;
|
|
8
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
9
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
10
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
11
|
-
}
|
|
12
|
-
Object.defineProperty(o, k2, desc);
|
|
13
|
-
}) : (function(o, m, k, k2) {
|
|
14
|
-
if (k2 === undefined) k2 = k;
|
|
15
|
-
o[k2] = m[k];
|
|
16
|
-
}));
|
|
17
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
18
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
19
|
-
}) : function(o, v) {
|
|
20
|
-
o["default"] = v;
|
|
21
|
-
});
|
|
22
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
23
|
-
var ownKeys = function(o) {
|
|
24
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
25
|
-
var ar = [];
|
|
26
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
27
|
-
return ar;
|
|
28
|
-
};
|
|
29
|
-
return ownKeys(o);
|
|
30
|
-
};
|
|
31
|
-
return function (mod) {
|
|
32
|
-
if (mod && mod.__esModule) return mod;
|
|
33
|
-
var result = {};
|
|
34
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
35
|
-
__setModuleDefault(result, mod);
|
|
36
|
-
return result;
|
|
37
|
-
};
|
|
38
|
-
})();
|
|
39
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
40
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
41
|
-
};
|
|
42
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
-
exports.SEND_SHORTCUT_PATTERN = void 0;
|
|
44
|
-
exports.resolveSendShortcutDevice = resolveSendShortcutDevice;
|
|
45
|
-
exports.resolveEffectiveDeviceName = resolveEffectiveDeviceName;
|
|
46
|
-
exports.resolveSendPlaylistDeviceName = resolveSendPlaylistDeviceName;
|
|
47
|
-
exports.sendPreparedPlaylistToDevice = sendPreparedPlaylistToDevice;
|
|
48
7
|
exports.validateRequirements = validateRequirements;
|
|
49
8
|
exports.applyPlaylistDefaults = applyPlaylistDefaults;
|
|
50
9
|
exports.buildPlaylistDirect = buildPlaylistDirect;
|
|
51
|
-
exports.buildPlaylist = buildPlaylist;
|
|
52
10
|
// Suppress Ed25519 experimental warning immediately
|
|
53
11
|
const originalEmitWarning = process.emitWarning;
|
|
54
12
|
process.emitWarning = function (warning, type, ctor) {
|
|
@@ -60,83 +18,10 @@ process.emitWarning = function (warning, type, ctor) {
|
|
|
60
18
|
}
|
|
61
19
|
return originalEmitWarning.apply(this, [warning, type, ctor]);
|
|
62
20
|
};
|
|
63
|
-
const chalk_1 = __importDefault(require("chalk"));
|
|
64
|
-
const readline = __importStar(require("readline"));
|
|
65
21
|
const config_1 = require("./config");
|
|
66
|
-
|
|
67
|
-
// Lazy load utilities and orchestrator to avoid circular dependencies
|
|
22
|
+
// Lazy load utilities to avoid circular dependencies
|
|
68
23
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
69
24
|
const getUtilities = () => require('./utilities');
|
|
70
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
71
|
-
const getIntentParser = () => require('./intent-parser');
|
|
72
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
73
|
-
const getAIOrchestrator = () => require('./ai-orchestrator');
|
|
74
|
-
/**
|
|
75
|
-
* Regex that matches the inline "send last / send playlist / send to <device>"
|
|
76
|
-
* shortcut inside the chat flow. Exported for testing.
|
|
77
|
-
*/
|
|
78
|
-
exports.SEND_SHORTCUT_PATTERN = /^send(?:\s+(?:last|playlist|the playlist))?(?:\s+to\s+(.+))?$/i;
|
|
79
|
-
/**
|
|
80
|
-
* Extract the target device from a sendMatch result, falling back to the CLI
|
|
81
|
-
* --device flag. Exported for testing.
|
|
82
|
-
*/
|
|
83
|
-
function resolveSendShortcutDevice(match, cliDefault) {
|
|
84
|
-
return match[1]?.trim() || cliDefault;
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Resolve the effective device name for a send operation.
|
|
88
|
-
* The intent (from NL parsing) takes precedence; CLI flag is the fallback.
|
|
89
|
-
* Exported for testing.
|
|
90
|
-
*/
|
|
91
|
-
function resolveEffectiveDeviceName(fromIntent, fromCLI) {
|
|
92
|
-
return fromIntent || fromCLI;
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Sanitize and resolve the device name for the direct send_playlist action path.
|
|
96
|
-
*
|
|
97
|
-
* The intent parser can emit literal "null" or "" as a device name string; these
|
|
98
|
-
* must be treated as absent so the CLI --device fallback is used instead.
|
|
99
|
-
* Exported for testing — mirrors the sanitization in confirmPlaylistForSending().
|
|
100
|
-
*/
|
|
101
|
-
function resolveSendPlaylistDeviceName(intentDeviceName, cliDeviceName) {
|
|
102
|
-
const sanitized = intentDeviceName === 'null' ||
|
|
103
|
-
intentDeviceName === '' ||
|
|
104
|
-
intentDeviceName === null ||
|
|
105
|
-
intentDeviceName === undefined
|
|
106
|
-
? undefined
|
|
107
|
-
: intentDeviceName;
|
|
108
|
-
return resolveEffectiveDeviceName(sanitized, cliDeviceName);
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Verify a playlist for device delivery and send it using the supplied transport.
|
|
112
|
-
*
|
|
113
|
-
* This helper keeps the send branches in `buildPlaylist` testable without
|
|
114
|
-
* duplicating the verification and transport wiring in tests.
|
|
115
|
-
*/
|
|
116
|
-
async function sendPreparedPlaylistToDevice(playlist, deps) {
|
|
117
|
-
const verificationResult = await deps.verifyPlaylist(playlist);
|
|
118
|
-
if (!verificationResult.valid) {
|
|
119
|
-
return {
|
|
120
|
-
success: false,
|
|
121
|
-
error: verificationResult.error || 'Failed to verify playlist before sending',
|
|
122
|
-
playlist: null,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
const sendResult = await deps.sendToDevice(playlist, deps.resolveDeviceName());
|
|
126
|
-
if (sendResult.success) {
|
|
127
|
-
return {
|
|
128
|
-
success: true,
|
|
129
|
-
playlist,
|
|
130
|
-
deviceName: sendResult.deviceName,
|
|
131
|
-
device: sendResult.device,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
return {
|
|
135
|
-
success: false,
|
|
136
|
-
error: sendResult.error || 'Failed to send playlist',
|
|
137
|
-
playlist: null,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
25
|
/**
|
|
141
26
|
* Validate and apply constraints to requirements
|
|
142
27
|
*
|
|
@@ -242,7 +127,6 @@ function applyPlaylistDefaults(settings = {}) {
|
|
|
242
127
|
}
|
|
243
128
|
/**
|
|
244
129
|
* Build playlist deterministically from structured parameters
|
|
245
|
-
* Path 1: Direct execution (no AI)
|
|
246
130
|
*
|
|
247
131
|
* @param {Object} params - Playlist parameters
|
|
248
132
|
* @param {Array<Object>} params.requirements - Array of requirements
|
|
@@ -261,246 +145,3 @@ async function buildPlaylistDirect(params, options = {}) {
|
|
|
261
145
|
utilities.initializeUtilities(config);
|
|
262
146
|
return await utilities.buildPlaylistDirect({ requirements, playlistSettings }, options);
|
|
263
147
|
}
|
|
264
|
-
/**
|
|
265
|
-
* Build playlist from natural language request
|
|
266
|
-
* Path 2: AI-driven execution (intent parser → AI orchestrator → utilities)
|
|
267
|
-
*
|
|
268
|
-
* @param {string} userRequest - Natural language request
|
|
269
|
-
* @param {Object} options - Options
|
|
270
|
-
* @param {boolean} [options.verbose=false] - Verbose output
|
|
271
|
-
* @param {string} [options.outputPath='playlist.json'] - Output path
|
|
272
|
-
* @param {string} [options.modelName] - AI model to use
|
|
273
|
-
* @param {boolean} [options.interactive=true] - Interactive mode (allow clarification prompts)
|
|
274
|
-
* @returns {Promise<Object>} Result with playlist
|
|
275
|
-
*/
|
|
276
|
-
async function buildPlaylist(userRequest, options = {}) {
|
|
277
|
-
const { verbose = false, outputPath = 'playlist.json', modelName, interactive = true, deviceName: defaultDeviceName, } = options;
|
|
278
|
-
// Enable verbose logging if requested
|
|
279
|
-
if (verbose) {
|
|
280
|
-
logger.setVerbose(true);
|
|
281
|
-
}
|
|
282
|
-
// Initialize utilities with config (indexer endpoint, API key, etc.)
|
|
283
|
-
const utilities = getUtilities();
|
|
284
|
-
const config = (0, config_1.getConfig)();
|
|
285
|
-
utilities.initializeUtilities(config);
|
|
286
|
-
try {
|
|
287
|
-
const trimmedRequest = userRequest.trim();
|
|
288
|
-
const sendMatch = trimmedRequest.match(/^send(?:\s+(?:last|playlist|the playlist))?(?:\s+to\s+(.+))?$/i);
|
|
289
|
-
if (sendMatch) {
|
|
290
|
-
const deviceName = sendMatch[1]?.trim() || defaultDeviceName;
|
|
291
|
-
const { confirmPlaylistForSending } = await Promise.resolve().then(() => __importStar(require('./utilities/playlist-send')));
|
|
292
|
-
const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('./utilities/playlist-verifier')));
|
|
293
|
-
const confirmation = await confirmPlaylistForSending(outputPath, deviceName);
|
|
294
|
-
if (!confirmation.success) {
|
|
295
|
-
if (confirmation.message) {
|
|
296
|
-
console.log(chalk_1.default.red(`\n${confirmation.message}`));
|
|
297
|
-
}
|
|
298
|
-
return {
|
|
299
|
-
success: false,
|
|
300
|
-
error: confirmation.error || 'Failed to prepare playlist for sending',
|
|
301
|
-
action: 'send_playlist',
|
|
302
|
-
playlist: null,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
const sendResult = await sendPreparedPlaylistToDevice(confirmation.playlist, {
|
|
306
|
-
verifyPlaylist,
|
|
307
|
-
sendToDevice: utilities.sendToDevice,
|
|
308
|
-
resolveDeviceName: () => confirmation.deviceName,
|
|
309
|
-
});
|
|
310
|
-
if (sendResult.success) {
|
|
311
|
-
console.log(chalk_1.default.green('\nPlaylist sent'));
|
|
312
|
-
if (sendResult.deviceName) {
|
|
313
|
-
console.log(chalk_1.default.dim(` Device: ${sendResult.deviceName}`));
|
|
314
|
-
}
|
|
315
|
-
console.log();
|
|
316
|
-
return {
|
|
317
|
-
success: true,
|
|
318
|
-
playlist: sendResult.playlist,
|
|
319
|
-
action: 'send_playlist',
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
console.log();
|
|
323
|
-
console.error(chalk_1.default.red('Send failed'));
|
|
324
|
-
if (sendResult.error) {
|
|
325
|
-
console.error(chalk_1.default.red(` ${sendResult.error}`));
|
|
326
|
-
}
|
|
327
|
-
return {
|
|
328
|
-
success: false,
|
|
329
|
-
error: sendResult.error || 'Failed to send playlist',
|
|
330
|
-
playlist: null,
|
|
331
|
-
action: 'send_playlist',
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
// STEP 1: INTENT PARSER
|
|
335
|
-
// Parse user intent into structured requirements
|
|
336
|
-
const { processIntentParserRequest } = getIntentParser();
|
|
337
|
-
let intentParserResult = await processIntentParserRequest(userRequest, {
|
|
338
|
-
modelName,
|
|
339
|
-
defaultDeviceName,
|
|
340
|
-
});
|
|
341
|
-
// Handle interactive clarification loop
|
|
342
|
-
while (intentParserResult.needsMoreInfo) {
|
|
343
|
-
if (!interactive) {
|
|
344
|
-
// Non-interactive mode: cannot ask for clarification
|
|
345
|
-
console.error(chalk_1.default.red('\nNeed more information, but running in non-interactive mode. Provide a complete request.'));
|
|
346
|
-
if (intentParserResult.question) {
|
|
347
|
-
console.error(chalk_1.default.yellow('\nQuestion: ') + intentParserResult.question);
|
|
348
|
-
}
|
|
349
|
-
process.exit(1);
|
|
350
|
-
}
|
|
351
|
-
// Ask user for clarification
|
|
352
|
-
const rl = readline.createInterface({
|
|
353
|
-
input: process.stdin,
|
|
354
|
-
output: process.stdout,
|
|
355
|
-
});
|
|
356
|
-
// Display the AI's question before asking for input
|
|
357
|
-
if (intentParserResult.question) {
|
|
358
|
-
console.log(chalk_1.default.cyan('\n') + intentParserResult.question);
|
|
359
|
-
}
|
|
360
|
-
const userResponse = await new Promise((resolve) => {
|
|
361
|
-
rl.question(chalk_1.default.yellow('Your response: '), (answer) => {
|
|
362
|
-
rl.close();
|
|
363
|
-
resolve(answer.trim());
|
|
364
|
-
});
|
|
365
|
-
});
|
|
366
|
-
if (!userResponse) {
|
|
367
|
-
console.error(chalk_1.default.red('\nNo response provided. Exiting.'));
|
|
368
|
-
process.exit(1);
|
|
369
|
-
}
|
|
370
|
-
console.log();
|
|
371
|
-
// Continue intent parser conversation
|
|
372
|
-
intentParserResult = await processIntentParserRequest(userResponse, {
|
|
373
|
-
modelName,
|
|
374
|
-
defaultDeviceName,
|
|
375
|
-
conversationContext: {
|
|
376
|
-
messages: intentParserResult.messages,
|
|
377
|
-
},
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
if (!intentParserResult.approved) {
|
|
381
|
-
console.error(chalk_1.default.red('\nRequest not approved by intent parser'));
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
384
|
-
const params = intentParserResult.params;
|
|
385
|
-
// NOTE: do NOT merge defaultDeviceName into playlistSettings here.
|
|
386
|
-
// buildPlaylistDirect (src/utilities/index.js) sends whenever
|
|
387
|
-
// playlistSettings.deviceName is defined, so setting it here would turn
|
|
388
|
-
// every build-only `chat --device` invocation into an implicit network send.
|
|
389
|
-
// The CLI --device flag is used only in the two explicit send paths below
|
|
390
|
-
// (sendMatch shortcut and send_playlist action) via resolveEffectiveDeviceName.
|
|
391
|
-
// Check if this is a send_playlist action
|
|
392
|
-
if (params && params.action === 'send_playlist') {
|
|
393
|
-
// Handle playlist sending directly
|
|
394
|
-
const sendParams = params;
|
|
395
|
-
const utilities = getUtilities();
|
|
396
|
-
const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('./utilities/playlist-verifier')));
|
|
397
|
-
console.log();
|
|
398
|
-
console.log(chalk_1.default.cyan('Sending to device'));
|
|
399
|
-
const sendResult = await sendPreparedPlaylistToDevice(sendParams.playlist, {
|
|
400
|
-
verifyPlaylist,
|
|
401
|
-
sendToDevice: utilities.sendToDevice,
|
|
402
|
-
resolveDeviceName: () => resolveSendPlaylistDeviceName(sendParams.deviceName, defaultDeviceName),
|
|
403
|
-
});
|
|
404
|
-
if (!sendResult.success) {
|
|
405
|
-
console.log();
|
|
406
|
-
console.error(chalk_1.default.red('Send failed'));
|
|
407
|
-
if (sendResult.error) {
|
|
408
|
-
console.error(chalk_1.default.red(` ${sendResult.error}`));
|
|
409
|
-
}
|
|
410
|
-
return {
|
|
411
|
-
success: false,
|
|
412
|
-
error: sendResult.error || 'Failed to send playlist',
|
|
413
|
-
playlist: null,
|
|
414
|
-
action: 'send_playlist',
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
console.log(chalk_1.default.green('\nPlaylist sent'));
|
|
418
|
-
if (sendResult.deviceName) {
|
|
419
|
-
console.log(chalk_1.default.dim(` Device: ${sendResult.deviceName}`));
|
|
420
|
-
}
|
|
421
|
-
console.log();
|
|
422
|
-
return {
|
|
423
|
-
success: true,
|
|
424
|
-
playlist: sendResult.playlist,
|
|
425
|
-
action: 'send_playlist',
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
// Check if this is a publish_playlist action
|
|
429
|
-
if (params && params.action === 'publish_playlist') {
|
|
430
|
-
// Publishing was already handled by intent parser, just return the result
|
|
431
|
-
const publishParams = params;
|
|
432
|
-
if (publishParams.success) {
|
|
433
|
-
return {
|
|
434
|
-
success: true,
|
|
435
|
-
action: 'publish_playlist',
|
|
436
|
-
playlistId: publishParams.playlistId,
|
|
437
|
-
feedServer: publishParams.feedServer,
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
else {
|
|
441
|
-
return {
|
|
442
|
-
success: false,
|
|
443
|
-
error: publishParams.error,
|
|
444
|
-
action: 'publish_playlist',
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
// STEP 2: AI ORCHESTRATOR (Function Calling)
|
|
449
|
-
// AI orchestrates function calls to build playlist
|
|
450
|
-
const { buildPlaylistWithAI } = getAIOrchestrator();
|
|
451
|
-
let result = await buildPlaylistWithAI(params, {
|
|
452
|
-
modelName,
|
|
453
|
-
verbose,
|
|
454
|
-
outputPath,
|
|
455
|
-
interactive,
|
|
456
|
-
defaultDeviceName,
|
|
457
|
-
});
|
|
458
|
-
// Handle confirmation loop in interactive mode
|
|
459
|
-
while (result.needsConfirmation && interactive) {
|
|
460
|
-
console.log(chalk_1.default.yellow('\n' + result.question));
|
|
461
|
-
console.log();
|
|
462
|
-
const rl = readline.createInterface({
|
|
463
|
-
input: process.stdin,
|
|
464
|
-
output: process.stdout,
|
|
465
|
-
});
|
|
466
|
-
const userResponse = await new Promise((resolve) => {
|
|
467
|
-
rl.question(chalk_1.default.cyan('Your response: '), (answer) => {
|
|
468
|
-
rl.close();
|
|
469
|
-
resolve(answer.trim());
|
|
470
|
-
});
|
|
471
|
-
});
|
|
472
|
-
if (!userResponse) {
|
|
473
|
-
console.error(chalk_1.default.red('\nNo response provided. Canceling.'));
|
|
474
|
-
return null;
|
|
475
|
-
}
|
|
476
|
-
console.log();
|
|
477
|
-
// Continue orchestrator with user's response
|
|
478
|
-
result = await buildPlaylistWithAI(result.params, {
|
|
479
|
-
modelName,
|
|
480
|
-
verbose,
|
|
481
|
-
outputPath,
|
|
482
|
-
interactive,
|
|
483
|
-
defaultDeviceName,
|
|
484
|
-
conversationContext: {
|
|
485
|
-
messages: result.messages,
|
|
486
|
-
userResponse,
|
|
487
|
-
},
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
// If no playlist was built, display the AI's message
|
|
491
|
-
if (!result.playlist && result.message) {
|
|
492
|
-
console.log(chalk_1.default.yellow('\n' + result.message));
|
|
493
|
-
}
|
|
494
|
-
else if (!result.playlist && result.error) {
|
|
495
|
-
console.error(chalk_1.default.red('\n' + result.error));
|
|
496
|
-
}
|
|
497
|
-
return result;
|
|
498
|
-
}
|
|
499
|
-
catch (error) {
|
|
500
|
-
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
501
|
-
if (verbose) {
|
|
502
|
-
console.error(chalk_1.default.dim(error.stack));
|
|
503
|
-
}
|
|
504
|
-
throw error;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
@@ -38,11 +38,16 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
38
38
|
})();
|
|
39
39
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
40
|
exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
|
|
41
|
+
exports.parseRetryAfterMs = parseRetryAfterMs;
|
|
41
42
|
exports.sendPlaylistToDevice = sendPlaylistToDevice;
|
|
42
43
|
const logger = __importStar(require("../logger"));
|
|
43
44
|
const ff1_compatibility_1 = require("./ff1-compatibility");
|
|
44
45
|
const SEND_RETRY_ATTEMPTS = 3;
|
|
45
46
|
const SEND_RETRY_BASE_DELAY_MS = 750;
|
|
47
|
+
const RATE_LIMIT_STATUS = 429;
|
|
48
|
+
// Cap how long a device-supplied Retry-After can stall the CLI, so a buggy or
|
|
49
|
+
// hostile device cannot pin a command for minutes/hours.
|
|
50
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
46
51
|
/**
|
|
47
52
|
* Sleep for a short duration before retrying transient network errors.
|
|
48
53
|
*
|
|
@@ -96,6 +101,36 @@ function isTransientDeviceNetworkError(error) {
|
|
|
96
101
|
: undefined;
|
|
97
102
|
return Boolean(causeCode && networkCodes.has(causeCode));
|
|
98
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* parseRetryAfterMs converts an HTTP `Retry-After` header into milliseconds.
|
|
106
|
+
*
|
|
107
|
+
* Supports both forms from RFC 7231: a delta-seconds integer (e.g. "5") and an
|
|
108
|
+
* HTTP-date (e.g. "Wed, 21 Oct 2025 07:28:00 GMT"). Returns undefined when the
|
|
109
|
+
* header is absent or unparseable, so callers can fall back to their own
|
|
110
|
+
* backoff. A past date clamps to 0 (retry immediately).
|
|
111
|
+
*
|
|
112
|
+
* @param {string | null | undefined} headerValue - Raw Retry-After header value
|
|
113
|
+
* @returns {number | undefined} Delay in milliseconds, or undefined if unknown
|
|
114
|
+
*/
|
|
115
|
+
function parseRetryAfterMs(headerValue) {
|
|
116
|
+
if (!headerValue) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
const trimmed = headerValue.trim();
|
|
120
|
+
if (/^\d+$/.test(trimmed)) {
|
|
121
|
+
return Number(trimmed) * 1000;
|
|
122
|
+
}
|
|
123
|
+
// A valid HTTP-date always contains letters (month/day names, or the ISO
|
|
124
|
+
// 'T'/'Z'). Requiring one rejects bare numeric junk (e.g. "-5", "5.5") that
|
|
125
|
+
// Date.parse would otherwise misinterpret as a date.
|
|
126
|
+
if (/[a-zA-Z]/.test(trimmed)) {
|
|
127
|
+
const dateMs = Date.parse(trimmed);
|
|
128
|
+
if (!Number.isNaN(dateMs)) {
|
|
129
|
+
return Math.max(0, dateMs - Date.now());
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
99
134
|
/**
|
|
100
135
|
* Send a DP1 playlist to an FF1 device using the cast API
|
|
101
136
|
*
|
|
@@ -185,6 +220,17 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
185
220
|
headers,
|
|
186
221
|
body: JSON.stringify(requestBody),
|
|
187
222
|
});
|
|
223
|
+
// The device sheds bursts of commands with HTTP 429 to protect itself.
|
|
224
|
+
// Treat it as transient: honor Retry-After when present, otherwise back
|
|
225
|
+
// off, and retry within the bounded attempt budget.
|
|
226
|
+
if (response.status === RATE_LIMIT_STATUS && attempt < SEND_RETRY_ATTEMPTS) {
|
|
227
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'));
|
|
228
|
+
const retryDelay = Math.min(retryAfterMs ?? SEND_RETRY_BASE_DELAY_MS * attempt, MAX_RETRY_DELAY_MS);
|
|
229
|
+
logger.warn(`Device rate-limited the command (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms`);
|
|
230
|
+
response = null;
|
|
231
|
+
await waitForRetry(retryDelay);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
188
234
|
break;
|
|
189
235
|
}
|
|
190
236
|
catch (error) {
|
|
@@ -212,6 +258,15 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
212
258
|
const errorText = await response.text();
|
|
213
259
|
logger.error(`Failed to cast to device: ${response.status} ${response.statusText}`);
|
|
214
260
|
logger.debug(`Error details: ${errorText}`);
|
|
261
|
+
if (response.status === RATE_LIMIT_STATUS) {
|
|
262
|
+
const deviceLabel = device.name || device.host;
|
|
263
|
+
return {
|
|
264
|
+
success: false,
|
|
265
|
+
error: `Device "${deviceLabel}" is busy and rate-limited the command`,
|
|
266
|
+
details: 'The Art Computer is shedding a burst of commands to protect itself. ' +
|
|
267
|
+
'Wait a few seconds and try again.',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
215
270
|
return {
|
|
216
271
|
success: false,
|
|
217
272
|
error: `Device returned error ${response.status}: ${response.statusText}`,
|
|
@@ -21,18 +21,43 @@ function isTimeBasedMedia(mimeType, sourceUrl) {
|
|
|
21
21
|
const candidates = [String(mimeType || '').toLowerCase(), detectMimeType(sourceUrl)];
|
|
22
22
|
return candidates.some((mime) => mime.startsWith('video/') || mime.startsWith('audio/'));
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* isInteractiveWeb reports whether an item is an interactive web page (HTML).
|
|
26
|
+
*
|
|
27
|
+
* Such sources have no intrinsic runtime AND no end-of-stream event, so they
|
|
28
|
+
* are timed differently from both video/audio (which end) and static images
|
|
29
|
+
* (which need a display duration): they play open-ended. The FF1 player
|
|
30
|
+
* renders these in a sandboxed iframe.
|
|
31
|
+
*
|
|
32
|
+
* Mirrors isTimeBasedMedia's two-signal approach: an explicit `text/html`
|
|
33
|
+
* MIME hint or a `.html`/`.htm` URL extension qualifies.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} [mimeType] - MIME type hint, if any
|
|
36
|
+
* @param {string} [sourceUrl] - Item source URL, used as an extension fallback
|
|
37
|
+
* @returns {boolean} True when the source is an interactive HTML page
|
|
38
|
+
*/
|
|
39
|
+
function isInteractiveWeb(mimeType, sourceUrl) {
|
|
40
|
+
const candidates = [String(mimeType || '').toLowerCase(), detectMimeType(sourceUrl)];
|
|
41
|
+
return candidates.some((mime) => mime.startsWith('text/html'));
|
|
42
|
+
}
|
|
24
43
|
/**
|
|
25
44
|
* applyItemTiming sets a DP1 item's playback timing per DP-1 §4.1.
|
|
26
45
|
*
|
|
27
46
|
* Invariant this function owns: an explicit numeric `duration` always wins
|
|
28
|
-
* and is stamped as-is. When `duration` is undefined/null ("auto")
|
|
29
|
-
* time-based source (video/audio) gets NO duration and
|
|
30
|
-
* so a conformant player MUST advance at
|
|
31
|
-
*
|
|
32
|
-
*
|
|
47
|
+
* and is stamped as-is. When `duration` is undefined/null ("auto"):
|
|
48
|
+
* - a time-based source (video/audio) gets NO duration and
|
|
49
|
+
* `display.loop: false`, so a conformant player MUST advance at
|
|
50
|
+
* end-of-stream — the media plays its natural length;
|
|
51
|
+
* - an interactive web page (HTML) gets NO duration either, but for the
|
|
52
|
+
* opposite reason: it has no end-of-stream event, so a conformant player
|
|
53
|
+
* parks on it indefinitely (the generative/interactive art keeps running);
|
|
54
|
+
* - static and code-based sources fall back to the configured
|
|
55
|
+
* `defaultDuration` because they have no intrinsic runtime to play out.
|
|
33
56
|
*
|
|
34
57
|
* Do not re-introduce an unconditional duration here: stamping a default on
|
|
35
|
-
* video items silently truncates or pads them
|
|
58
|
+
* video items silently truncates or pads them, and stamping one on an HTML
|
|
59
|
+
* page cuts the artwork off and restarts it every `defaultDuration` seconds
|
|
60
|
+
* (the bugs this guards against).
|
|
36
61
|
*
|
|
37
62
|
* @param {Object} item - DP1 playlist item (mutated in place)
|
|
38
63
|
* @param {Object} mediaHints - Media signals for the time-based check
|
|
@@ -50,6 +75,11 @@ function applyItemTiming(item, mediaHints = {}, duration) {
|
|
|
50
75
|
item.display = { ...(item.display || {}), loop: false };
|
|
51
76
|
return item;
|
|
52
77
|
}
|
|
78
|
+
if (isInteractiveWeb(mediaHints.mimeType, mediaHints.sourceUrl)) {
|
|
79
|
+
// No duration: an open-ended interactive page should play until the
|
|
80
|
+
// playlist advances for another reason, not on a fixed timer.
|
|
81
|
+
return item;
|
|
82
|
+
}
|
|
53
83
|
item.duration = getDefaultStaticDuration();
|
|
54
84
|
return item;
|
|
55
85
|
}
|
|
@@ -496,20 +526,24 @@ function validateDP1Playlist(playlist) {
|
|
|
496
526
|
};
|
|
497
527
|
}
|
|
498
528
|
/**
|
|
499
|
-
* Detect MIME type from URL or file extension
|
|
529
|
+
* Detect MIME type from a URL or file extension.
|
|
500
530
|
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
531
|
+
* Maps known media/web extensions to a MIME type. Returns '' (empty) when the
|
|
532
|
+
* extension is unknown or absent — the type is genuinely undetermined, and
|
|
533
|
+
* callers decide the fallback. (Do NOT default to 'image/png': that silently
|
|
534
|
+
* misclassified HTML pages and extensionless URLs as static images, stamping
|
|
535
|
+
* them with a display duration that cut interactive artworks off.)
|
|
503
536
|
*
|
|
504
|
-
* @param {string} url - Media URL
|
|
505
|
-
* @returns {string} MIME type
|
|
537
|
+
* @param {string} url - Media or page URL
|
|
538
|
+
* @returns {string} MIME type, or '' when undetermined
|
|
506
539
|
* @example
|
|
507
|
-
*
|
|
508
|
-
* //
|
|
540
|
+
* detectMimeType('https://example.com/image.png'); // 'image/png'
|
|
541
|
+
* detectMimeType('https://example.com/art.html'); // 'text/html'
|
|
542
|
+
* detectMimeType('https://example.com/output/abc'); // ''
|
|
509
543
|
*/
|
|
510
544
|
function detectMimeType(url) {
|
|
511
545
|
if (!url) {
|
|
512
|
-
return '
|
|
546
|
+
return '';
|
|
513
547
|
}
|
|
514
548
|
const extension = url.split('.').pop()?.toLowerCase().split('?')[0];
|
|
515
549
|
const mimeTypes = {
|
|
@@ -519,6 +553,8 @@ function detectMimeType(url) {
|
|
|
519
553
|
gif: 'image/gif',
|
|
520
554
|
webp: 'image/webp',
|
|
521
555
|
svg: 'image/svg+xml',
|
|
556
|
+
html: 'text/html',
|
|
557
|
+
htm: 'text/html',
|
|
522
558
|
mp4: 'video/mp4',
|
|
523
559
|
webm: 'video/webm',
|
|
524
560
|
mp3: 'audio/mpeg',
|
|
@@ -526,7 +562,7 @@ function detectMimeType(url) {
|
|
|
526
562
|
glb: 'model/gltf-binary',
|
|
527
563
|
gltf: 'model/gltf+json',
|
|
528
564
|
};
|
|
529
|
-
return mimeTypes[extension] || '
|
|
565
|
+
return mimeTypes[extension] || '';
|
|
530
566
|
}
|
|
531
567
|
/**
|
|
532
568
|
* Build a single DP1 playlist item from a URL
|
|
@@ -584,7 +620,13 @@ function buildUrlItem(url, duration, options = {}) {
|
|
|
584
620
|
margin: 0,
|
|
585
621
|
},
|
|
586
622
|
};
|
|
587
|
-
|
|
623
|
+
// A URL handed to `play` that isn't a recognized media file is treated as an
|
|
624
|
+
// interactive web page: the FF1 renders unknown URLs in a sandboxed iframe,
|
|
625
|
+
// so an explicit caller hint aside, "unknown" means web, not static image.
|
|
626
|
+
// This routes such items through the open-ended (no-duration) timing path
|
|
627
|
+
// instead of the static-image default.
|
|
628
|
+
const mimeHint = options.mimeType || (detectMimeType(sourceUrl) === '' ? 'text/html' : undefined);
|
|
629
|
+
return applyItemTiming(item, { sourceUrl, mimeType: mimeHint }, duration);
|
|
588
630
|
}
|
|
589
631
|
module.exports = {
|
|
590
632
|
convertTokenToDP1Item,
|
|
@@ -595,6 +637,7 @@ module.exports = {
|
|
|
595
637
|
validateDP1Playlist,
|
|
596
638
|
detectMimeType,
|
|
597
639
|
isTimeBasedMedia,
|
|
640
|
+
isInteractiveWeb,
|
|
598
641
|
applyItemTiming,
|
|
599
642
|
buildUrlItem,
|
|
600
643
|
};
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -21,35 +21,10 @@ npm run dev -- config show
|
|
|
21
21
|
|
|
22
22
|
## Top‑level fields
|
|
23
23
|
|
|
24
|
-
- **defaultModel** (string)
|
|
25
|
-
- The default AI model key to use. Must match a key under `models`.
|
|
26
|
-
- Used by orchestration to pick API, timeouts, and model identifier.
|
|
27
|
-
|
|
28
24
|
- **defaultDuration** (number, seconds)
|
|
29
25
|
- Default per‑item display duration for media without an intrinsic runtime (images, code-based works). Falls back to 10s when unset.
|
|
30
26
|
- Does NOT apply to video/audio items: with no explicit duration those are emitted without `duration` and with `display.loop: false`, so a DP-1 player advances at end-of-stream (§4.1) — the media plays its natural length.
|
|
31
27
|
|
|
32
|
-
## models
|
|
33
|
-
|
|
34
|
-
Each key under `models` defines a model configuration used by the AI orchestrator.
|
|
35
|
-
|
|
36
|
-
- `<modelName>.apiKey` (string): API key for the provider.
|
|
37
|
-
- `<modelName>.baseURL` (string): Base API URL.
|
|
38
|
-
- `<modelName>.model` (string): Model identifier (e.g., `grok-beta`, `gpt-4o`).
|
|
39
|
-
- `<modelName>.availableModels` (string[], optional): Display/help only.
|
|
40
|
-
- `<modelName>.timeout` (number, ms): HTTP timeout for requests.
|
|
41
|
-
- `<modelName>.maxRetries` (number): Retry count for requests.
|
|
42
|
-
- `<modelName>.temperature` (number): Generation temperature.
|
|
43
|
-
- `<modelName>.maxTokens` (number): Token cap.
|
|
44
|
-
- `<modelName>.supportsFunctionCalling` (boolean): Must be true; otherwise the CLI rejects the model.
|
|
45
|
-
|
|
46
|
-
Environment variable helpers:
|
|
47
|
-
|
|
48
|
-
- Anthropic (Claude): `ANTHROPIC_API_KEY`
|
|
49
|
-
- Grok: `GROK_API_KEY`, `GROK_MODEL`, `GROK_API_BASE_URL`
|
|
50
|
-
- OpenAI: `OPENAI_API_KEY`
|
|
51
|
-
- Gemini: `GEMINI_API_KEY`
|
|
52
|
-
|
|
53
28
|
## browser
|
|
54
29
|
|
|
55
30
|
Optional settings used where headless/browser‑like behavior is needed.
|
|
@@ -182,15 +157,6 @@ Minimal `config.json` example (selected fields):
|
|
|
182
157
|
|
|
183
158
|
```json
|
|
184
159
|
{
|
|
185
|
-
"defaultModel": "claude",
|
|
186
|
-
"models": {
|
|
187
|
-
"claude": {
|
|
188
|
-
"apiKey": "sk-ant-your-api-key-here",
|
|
189
|
-
"baseURL": "https://api.anthropic.com/v1/",
|
|
190
|
-
"model": "claude-sonnet-4-6",
|
|
191
|
-
"supportsFunctionCalling": true
|
|
192
|
-
}
|
|
193
|
-
},
|
|
194
160
|
"defaultDuration": 10,
|
|
195
161
|
"playlist": {
|
|
196
162
|
"privateKey": "your_ed25519_private_key_hex_or_base64_here",
|