ff1-cli 1.0.2 → 1.0.3
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 +4 -2
- package/config.json.example +10 -8
- package/dist/index.js +255 -80
- package/dist/src/ai-orchestrator/index.js +62 -5
- package/dist/src/config.js +12 -12
- package/dist/src/intent-parser/index.js +110 -84
- package/dist/src/intent-parser/utils.js +5 -2
- package/dist/src/logger.js +10 -0
- package/dist/src/utilities/ff1-compatibility.js +269 -0
- package/dist/src/utilities/ff1-device.js +9 -27
- package/dist/src/utilities/ff1-discovery.js +147 -0
- package/dist/src/utilities/functions.js +8 -26
- package/dist/src/utilities/index.js +9 -3
- package/dist/src/utilities/playlist-send.js +36 -17
- package/dist/src/utilities/playlist-source.js +77 -0
- package/dist/src/utilities/ssh-access.js +145 -0
- package/docs/CONFIGURATION.md +20 -6
- package/docs/EXAMPLES.md +13 -4
- package/docs/README.md +25 -5
- package/docs/RELEASING.md +6 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -42,15 +42,17 @@ ff1 play "https://example.com/video.mp4" -d "Living Room Display" --skip-verify
|
|
|
42
42
|
```bash
|
|
43
43
|
npm install
|
|
44
44
|
npm run dev -- config init
|
|
45
|
-
npm run dev chat
|
|
45
|
+
npm run dev -- chat
|
|
46
46
|
npm run dev -- play "https://example.com/video.mp4" -d "Living Room Display" --skip-verify
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
## Documentation
|
|
50
50
|
|
|
51
|
-
- Getting started
|
|
51
|
+
- Getting started and usage: `./docs/README.md`
|
|
52
|
+
- Configuration: `./docs/CONFIGURATION.md`
|
|
52
53
|
- Function calling architecture: `./docs/FUNCTION_CALLING.md`
|
|
53
54
|
- Examples: `./docs/EXAMPLES.md`
|
|
55
|
+
- SSH access: `ff1 ssh enable|disable` in `./docs/README.md`
|
|
54
56
|
|
|
55
57
|
## Scripts
|
|
56
58
|
|
package/config.json.example
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"defaultModel": "
|
|
2
|
+
"defaultModel": "grok",
|
|
3
3
|
"models": {
|
|
4
4
|
"grok": {
|
|
5
5
|
"apiKey": "YOUR_GROK_API_KEY_FROM_X_AI_CONSOLE",
|
|
6
6
|
"baseURL": "https://api.x.ai/v1",
|
|
7
|
-
"model": "grok-
|
|
7
|
+
"model": "grok-beta",
|
|
8
8
|
"availableModels": [
|
|
9
|
-
"grok-
|
|
10
|
-
"grok-
|
|
11
|
-
"grok-
|
|
9
|
+
"grok-beta",
|
|
10
|
+
"grok-2-1212",
|
|
11
|
+
"grok-2-vision-1212"
|
|
12
12
|
],
|
|
13
13
|
"timeout": 120000,
|
|
14
14
|
"maxRetries": 3,
|
|
@@ -16,11 +16,13 @@
|
|
|
16
16
|
"maxTokens": 4000,
|
|
17
17
|
"supportsFunctionCalling": true
|
|
18
18
|
},
|
|
19
|
-
"
|
|
19
|
+
"chatgpt": {
|
|
20
20
|
"apiKey": "YOUR_OPENAI_API_KEY_FROM_PLATFORM_OPENAI_COM",
|
|
21
21
|
"baseURL": "https://api.openai.com/v1",
|
|
22
|
-
"model": "gpt-
|
|
22
|
+
"model": "gpt-4.1",
|
|
23
23
|
"availableModels": [
|
|
24
|
+
"gpt-4.1",
|
|
25
|
+
"gpt-4.1-mini",
|
|
24
26
|
"gpt-4o",
|
|
25
27
|
"gpt-4o-mini",
|
|
26
28
|
"gpt-4-turbo"
|
|
@@ -63,7 +65,7 @@
|
|
|
63
65
|
}
|
|
64
66
|
],
|
|
65
67
|
"playlist": {
|
|
66
|
-
"privateKey": "
|
|
68
|
+
"privateKey": "YOUR_ED25519_PRIVATE_KEY_BASE64_OR_HEX"
|
|
67
69
|
},
|
|
68
70
|
"ff1Devices": {
|
|
69
71
|
"devices": [
|
package/dist/index.js
CHANGED
|
@@ -38,7 +38,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
};
|
|
39
39
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
40
|
// Suppress punycode deprecation warnings from dependencies
|
|
41
|
-
process.removeAllListeners('warning');
|
|
42
41
|
process.on('warning', (warning) => {
|
|
43
42
|
if (warning.name === 'DeprecationWarning' && warning.message.includes('punycode')) {
|
|
44
43
|
return; // Ignore punycode deprecation warnings from dependencies
|
|
@@ -55,6 +54,8 @@ const fs_2 = require("fs");
|
|
|
55
54
|
const path_1 = require("path");
|
|
56
55
|
const config_1 = require("./src/config");
|
|
57
56
|
const main_1 = require("./src/main");
|
|
57
|
+
const ff1_discovery_1 = require("./src/utilities/ff1-discovery");
|
|
58
|
+
const playlist_source_1 = require("./src/utilities/playlist-source");
|
|
58
59
|
// Load version from package.json
|
|
59
60
|
// Try built location first (dist/index.js -> ../package.json)
|
|
60
61
|
// Fall back to dev location (index.ts -> ./package.json)
|
|
@@ -76,7 +77,7 @@ const placeholderPattern = /YOUR_|your_/;
|
|
|
76
77
|
* @param {string} outputPath - Path where the playlist was saved
|
|
77
78
|
*/
|
|
78
79
|
function displayPlaylistSummary(playlist, outputPath) {
|
|
79
|
-
console.log(chalk_1.default.green('\nPlaylist
|
|
80
|
+
console.log(chalk_1.default.green('\nPlaylist saved'));
|
|
80
81
|
console.log(chalk_1.default.dim(` Output: ./${outputPath}`));
|
|
81
82
|
console.log(chalk_1.default.dim(' Next: send last | publish playlist'));
|
|
82
83
|
console.log();
|
|
@@ -116,8 +117,49 @@ async function ensureConfigFile() {
|
|
|
116
117
|
const createdPath = await (0, config_1.createSampleConfig)(userPath);
|
|
117
118
|
return { path: createdPath, created: true };
|
|
118
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Parse TTL duration string into seconds.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} ttl - Duration string (e.g. "900", "15m", "2h")
|
|
124
|
+
* @returns {number} TTL in seconds
|
|
125
|
+
* @throws {Error} When ttl format is invalid
|
|
126
|
+
*/
|
|
127
|
+
function parseTtlSeconds(ttl) {
|
|
128
|
+
const trimmed = ttl.trim();
|
|
129
|
+
const match = trimmed.match(/^(\d+)([smh]?)$/i);
|
|
130
|
+
if (!match) {
|
|
131
|
+
throw new Error('TTL must be a number of seconds or a duration like 15m or 2h');
|
|
132
|
+
}
|
|
133
|
+
const value = parseInt(match[1], 10);
|
|
134
|
+
const unit = match[2].toLowerCase();
|
|
135
|
+
if (Number.isNaN(value)) {
|
|
136
|
+
throw new Error('TTL value is not a number');
|
|
137
|
+
}
|
|
138
|
+
if (unit === 'm') {
|
|
139
|
+
return value * 60;
|
|
140
|
+
}
|
|
141
|
+
if (unit === 'h') {
|
|
142
|
+
return value * 60 * 60;
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Read an SSH public key from a file.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} keyPath - Path to public key file
|
|
150
|
+
* @returns {Promise<string>} Public key contents
|
|
151
|
+
* @throws {Error} When the file is empty or unreadable
|
|
152
|
+
*/
|
|
153
|
+
async function readPublicKeyFile(keyPath) {
|
|
154
|
+
const content = await fs_1.promises.readFile(keyPath, 'utf-8');
|
|
155
|
+
const trimmed = content.trim();
|
|
156
|
+
if (!trimmed) {
|
|
157
|
+
throw new Error('Public key file is empty');
|
|
158
|
+
}
|
|
159
|
+
return trimmed;
|
|
160
|
+
}
|
|
119
161
|
function normalizeDeviceHost(host) {
|
|
120
|
-
let normalized = host.trim();
|
|
162
|
+
let normalized = host.trim().replace(/\.$/, '');
|
|
121
163
|
if (!normalized) {
|
|
122
164
|
return normalized;
|
|
123
165
|
}
|
|
@@ -133,6 +175,81 @@ function normalizeDeviceHost(host) {
|
|
|
133
175
|
return normalized;
|
|
134
176
|
}
|
|
135
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Print a focused failure for playlist source loading problems.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} source - Playlist source value
|
|
182
|
+
* @param {Error} error - Load or parse error
|
|
183
|
+
*/
|
|
184
|
+
function printPlaylistSourceLoadFailure(source, error) {
|
|
185
|
+
const isUrl = (0, playlist_source_1.isPlaylistSourceUrl)(source);
|
|
186
|
+
if (isUrl) {
|
|
187
|
+
console.error(chalk_1.default.red('\nCould not load hosted playlist URL'));
|
|
188
|
+
console.error(chalk_1.default.red(` Source: ${source}`));
|
|
189
|
+
console.error(chalk_1.default.red(` Error: ${error.message}`));
|
|
190
|
+
console.log(chalk_1.default.yellow('\n Hint:'));
|
|
191
|
+
console.log(chalk_1.default.yellow(' • Check the URL is reachable'));
|
|
192
|
+
console.log(chalk_1.default.yellow(' • Confirm the response is JSON'));
|
|
193
|
+
console.log(chalk_1.default.yellow(' • Use a local file path if network access is unavailable'));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
console.error(chalk_1.default.red(`\nCould not load playlist file`));
|
|
197
|
+
console.error(chalk_1.default.red(` Source: ${source}`));
|
|
198
|
+
console.error(chalk_1.default.red(` Error: ${error.message}`));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Print playlist verification failure details consistently.
|
|
202
|
+
*
|
|
203
|
+
* @param {Object} verifyResult - DP-1 verification result
|
|
204
|
+
* @param {string} [source] - Optional source label
|
|
205
|
+
*/
|
|
206
|
+
function printPlaylistVerificationFailure(verifyResult, source) {
|
|
207
|
+
console.error(chalk_1.default.red(`\nPlaylist verification failed:${source ? ` (${source})` : ''}`), verifyResult.error);
|
|
208
|
+
if (verifyResult.details && verifyResult.details.length > 0) {
|
|
209
|
+
console.log(chalk_1.default.yellow('\n Validation errors:'));
|
|
210
|
+
verifyResult.details.forEach((detail) => {
|
|
211
|
+
console.log(chalk_1.default.yellow(` • ${detail.path}: ${detail.message}`));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
console.log(chalk_1.default.yellow('\n Use --skip-verify to send anyway (not recommended)\n'));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Load and verify a DP-1 playlist from local file or hosted URL.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} source - Playlist source value
|
|
220
|
+
* @returns {Promise<Object>} Verification result with parsed playlist when valid
|
|
221
|
+
*/
|
|
222
|
+
async function verifyPlaylistSource(source) {
|
|
223
|
+
const loaded = await (0, playlist_source_1.loadPlaylistSource)(source);
|
|
224
|
+
const verifier = await Promise.resolve().then(() => __importStar(require('./src/utilities/playlist-verifier')));
|
|
225
|
+
const { verifyPlaylist } = verifier;
|
|
226
|
+
const verifyResult = verifyPlaylist(loaded.playlist);
|
|
227
|
+
return {
|
|
228
|
+
...verifyResult,
|
|
229
|
+
playlist: verifyResult.valid ? loaded.playlist : undefined,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Run a shared verify/validate command flow.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} source - Playlist source path or URL
|
|
236
|
+
*/
|
|
237
|
+
async function runVerifyCommand(source) {
|
|
238
|
+
try {
|
|
239
|
+
console.log(chalk_1.default.blue('\nVerify playlist\n'));
|
|
240
|
+
const verifier = await Promise.resolve().then(() => __importStar(require('./src/utilities/playlist-verifier')));
|
|
241
|
+
const { printVerificationResult } = verifier;
|
|
242
|
+
const result = await verifyPlaylistSource(source);
|
|
243
|
+
printVerificationResult(result, source);
|
|
244
|
+
if (!result.valid) {
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
printPlaylistSourceLoadFailure(source, error);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
136
253
|
async function promptYesNo(ask, question, defaultYes = true) {
|
|
137
254
|
const suffix = defaultYes ? 'Y/n' : 'y/N';
|
|
138
255
|
const answer = (await ask(`${question} [${suffix}] `)).trim().toLowerCase();
|
|
@@ -203,7 +320,6 @@ program
|
|
|
203
320
|
const keyHelpUrls = {
|
|
204
321
|
grok: 'https://console.x.ai/',
|
|
205
322
|
gpt: 'https://platform.openai.com/api-keys',
|
|
206
|
-
chatgpt: 'https://platform.openai.com/api-keys',
|
|
207
323
|
gemini: 'https://aistudio.google.com/app/apikey',
|
|
208
324
|
};
|
|
209
325
|
if (!hasApiKeyForModel) {
|
|
@@ -248,6 +364,41 @@ program
|
|
|
248
364
|
};
|
|
249
365
|
}
|
|
250
366
|
const existingDevice = config.ff1Devices?.devices?.[0];
|
|
367
|
+
const discoveryResult = await (0, ff1_discovery_1.discoverFF1Devices)({ timeoutMs: 2000 });
|
|
368
|
+
const discoveredDevices = discoveryResult.devices;
|
|
369
|
+
let selectedDeviceIndex = null;
|
|
370
|
+
if (discoveryResult.error && discoveredDevices.length === 0) {
|
|
371
|
+
const errorMessage = discoveryResult.error.endsWith('.')
|
|
372
|
+
? discoveryResult.error
|
|
373
|
+
: `${discoveryResult.error}.`;
|
|
374
|
+
console.log(chalk_1.default.dim(`mDNS discovery failed: ${errorMessage} Continuing with manual entry.`));
|
|
375
|
+
}
|
|
376
|
+
else if (discoveryResult.error) {
|
|
377
|
+
console.log(chalk_1.default.dim(`mDNS discovery warning: ${discoveryResult.error}`));
|
|
378
|
+
}
|
|
379
|
+
if (discoveredDevices.length > 0) {
|
|
380
|
+
console.log(chalk_1.default.green('\nFF1 devices on your network:'));
|
|
381
|
+
discoveredDevices.forEach((device, index) => {
|
|
382
|
+
const displayId = device.id || device.name || device.host;
|
|
383
|
+
console.log(chalk_1.default.dim(` ${index + 1}) ${displayId}`));
|
|
384
|
+
});
|
|
385
|
+
const selectionAnswer = await ask(`Select device [1-${discoveredDevices.length}] or press Enter to skip: `);
|
|
386
|
+
if (selectionAnswer) {
|
|
387
|
+
const parsedIndex = Number.parseInt(selectionAnswer, 10);
|
|
388
|
+
if (Number.isNaN(parsedIndex) ||
|
|
389
|
+
parsedIndex < 1 ||
|
|
390
|
+
parsedIndex > discoveredDevices.length) {
|
|
391
|
+
console.log(chalk_1.default.red('Invalid selection. Skipping auto-discovery.'));
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
selectedDeviceIndex = parsedIndex - 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
else if (!discoveryResult.error) {
|
|
399
|
+
console.log(chalk_1.default.dim('No FF1 devices found via mDNS. Continuing with manual entry.'));
|
|
400
|
+
}
|
|
401
|
+
const selectedDevice = selectedDeviceIndex === null ? null : discoveredDevices[selectedDeviceIndex];
|
|
251
402
|
{
|
|
252
403
|
const existingHost = existingDevice?.host || '';
|
|
253
404
|
let rawDefaultDeviceId = '';
|
|
@@ -263,25 +414,32 @@ program
|
|
|
263
414
|
}
|
|
264
415
|
}
|
|
265
416
|
const defaultDeviceId = isMissingConfigValue(rawDefaultDeviceId) ? '' : rawDefaultDeviceId;
|
|
266
|
-
const idPrompt = defaultDeviceId
|
|
267
|
-
? `Device ID (e.g. ff1-ABCD1234) [${defaultDeviceId}]: `
|
|
268
|
-
: 'Device ID (e.g. ff1-ABCD1234): ';
|
|
269
|
-
const idAnswer = await ask(idPrompt);
|
|
270
|
-
const rawDeviceId = idAnswer || defaultDeviceId;
|
|
271
417
|
let hostValue = '';
|
|
272
|
-
if (
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
418
|
+
if (selectedDevice) {
|
|
419
|
+
hostValue = normalizeDeviceHost(`${selectedDevice.host}:${selectedDevice.port}`);
|
|
420
|
+
console.log(chalk_1.default.dim(`Using discovered device: ${selectedDevice.name} (${selectedDevice.host}:${selectedDevice.port})`));
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
const idPrompt = defaultDeviceId
|
|
424
|
+
? `Device ID (e.g. ff1-ABCD1234) [${defaultDeviceId}]: `
|
|
425
|
+
: 'Device ID (e.g. ff1-ABCD1234): ';
|
|
426
|
+
const idAnswer = await ask(idPrompt);
|
|
427
|
+
const rawDeviceId = idAnswer || defaultDeviceId;
|
|
428
|
+
if (rawDeviceId) {
|
|
429
|
+
const looksLikeHost = rawDeviceId.includes('.') ||
|
|
430
|
+
rawDeviceId.includes(':') ||
|
|
431
|
+
rawDeviceId.startsWith('http');
|
|
432
|
+
if (looksLikeHost) {
|
|
433
|
+
hostValue = normalizeDeviceHost(rawDeviceId);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
const deviceId = rawDeviceId.startsWith('ff1-') ? rawDeviceId : `ff1-${rawDeviceId}`;
|
|
437
|
+
hostValue = normalizeDeviceHost(`${deviceId}.local`);
|
|
438
|
+
}
|
|
282
439
|
}
|
|
283
440
|
}
|
|
284
|
-
const
|
|
441
|
+
const discoveredName = selectedDevice?.name || selectedDevice?.id || '';
|
|
442
|
+
const rawName = existingDevice?.name || discoveredName || 'ff1';
|
|
285
443
|
const defaultName = isMissingConfigValue(rawName) ? '' : rawName;
|
|
286
444
|
const namePrompt = defaultName
|
|
287
445
|
? `Device name (kitchen, office, etc.) [${defaultName}]: `
|
|
@@ -434,7 +592,7 @@ program
|
|
|
434
592
|
});
|
|
435
593
|
// Print final summary
|
|
436
594
|
if (result && result.playlist) {
|
|
437
|
-
console.log(chalk_1.default.green('\nPlaylist
|
|
595
|
+
console.log(chalk_1.default.green('\nPlaylist saved'));
|
|
438
596
|
console.log(chalk_1.default.dim(` Title: ${result.playlist.title}`));
|
|
439
597
|
console.log(chalk_1.default.dim(` Items: ${result.playlist.items?.length || 0}`));
|
|
440
598
|
console.log(chalk_1.default.dim(` Output: ${options.output}\n`));
|
|
@@ -537,48 +695,16 @@ program
|
|
|
537
695
|
program
|
|
538
696
|
.command('verify')
|
|
539
697
|
.description('Verify a DP1 playlist file against DP-1 specification')
|
|
540
|
-
.argument('<file>', 'Path to the playlist file')
|
|
698
|
+
.argument('<file>', 'Path to the playlist file or hosted playlist URL')
|
|
541
699
|
.action(async (file) => {
|
|
542
|
-
|
|
543
|
-
console.log(chalk_1.default.blue('\nVerify playlist\n'));
|
|
544
|
-
// Import the verification utility
|
|
545
|
-
const verifier = await Promise.resolve().then(() => __importStar(require('./src/utilities/playlist-verifier')));
|
|
546
|
-
const { verifyPlaylistFile, printVerificationResult } = verifier;
|
|
547
|
-
// Verify the playlist
|
|
548
|
-
const result = await verifyPlaylistFile(file);
|
|
549
|
-
// Print results
|
|
550
|
-
printVerificationResult(result, file);
|
|
551
|
-
if (!result.valid) {
|
|
552
|
-
process.exit(1);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
catch (error) {
|
|
556
|
-
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
557
|
-
process.exit(1);
|
|
558
|
-
}
|
|
700
|
+
await runVerifyCommand(file);
|
|
559
701
|
});
|
|
560
702
|
program
|
|
561
703
|
.command('validate')
|
|
562
704
|
.description('Validate a DP1 playlist file (alias for verify)')
|
|
563
|
-
.argument('<file>', 'Path to the playlist file')
|
|
705
|
+
.argument('<file>', 'Path to the playlist file or hosted playlist URL')
|
|
564
706
|
.action(async (file) => {
|
|
565
|
-
|
|
566
|
-
console.log(chalk_1.default.blue('\nVerify playlist\n'));
|
|
567
|
-
// Import the verification utility
|
|
568
|
-
const verifier = await Promise.resolve().then(() => __importStar(require('./src/utilities/playlist-verifier')));
|
|
569
|
-
const { verifyPlaylistFile, printVerificationResult } = verifier;
|
|
570
|
-
// Verify the playlist
|
|
571
|
-
const result = await verifyPlaylistFile(file);
|
|
572
|
-
// Print results
|
|
573
|
-
printVerificationResult(result, file);
|
|
574
|
-
if (!result.valid) {
|
|
575
|
-
process.exit(1);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
catch (error) {
|
|
579
|
-
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
580
|
-
process.exit(1);
|
|
581
|
-
}
|
|
707
|
+
await runVerifyCommand(file);
|
|
582
708
|
});
|
|
583
709
|
program
|
|
584
710
|
.command('sign')
|
|
@@ -638,14 +764,7 @@ program
|
|
|
638
764
|
const { verifyPlaylist } = verifier;
|
|
639
765
|
const verifyResult = verifyPlaylist(playlist);
|
|
640
766
|
if (!verifyResult.valid) {
|
|
641
|
-
|
|
642
|
-
if (verifyResult.details && verifyResult.details.length > 0) {
|
|
643
|
-
console.log(chalk_1.default.yellow('\n Validation errors:'));
|
|
644
|
-
verifyResult.details.forEach((detail) => {
|
|
645
|
-
console.log(chalk_1.default.yellow(` • ${detail.path}: ${detail.message}`));
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
console.log(chalk_1.default.yellow('\n Use --skip-verify to send anyway (not recommended)\n'));
|
|
767
|
+
printPlaylistVerificationFailure(verifyResult);
|
|
649
768
|
process.exit(1);
|
|
650
769
|
}
|
|
651
770
|
}
|
|
@@ -680,31 +799,23 @@ program
|
|
|
680
799
|
});
|
|
681
800
|
program
|
|
682
801
|
.command('send')
|
|
683
|
-
.description('Send a playlist
|
|
684
|
-
.argument('<file>', '
|
|
802
|
+
.description('Send a playlist to an FF1 device')
|
|
803
|
+
.argument('<file>', 'Playlist file path or hosted playlist URL')
|
|
685
804
|
.option('-d, --device <name>', 'Device name (uses first device if not specified)')
|
|
686
805
|
.option('--skip-verify', 'Skip playlist verification before sending')
|
|
687
806
|
.action(async (file, options) => {
|
|
688
807
|
try {
|
|
689
808
|
console.log(chalk_1.default.blue('\nSend playlist to FF1\n'));
|
|
690
|
-
|
|
691
|
-
const
|
|
692
|
-
const playlist = JSON.parse(content);
|
|
809
|
+
const playlistResult = await (0, playlist_source_1.loadPlaylistSource)(file);
|
|
810
|
+
const playlist = playlistResult.playlist;
|
|
693
811
|
// Verify playlist before sending (unless skipped)
|
|
694
812
|
if (!options.skipVerify) {
|
|
695
|
-
console.log(chalk_1.default.cyan(
|
|
813
|
+
console.log(chalk_1.default.cyan(`Verify playlist (${playlistResult.sourceType}: ${playlistResult.source})`));
|
|
696
814
|
const verifier = await Promise.resolve().then(() => __importStar(require('./src/utilities/playlist-verifier')));
|
|
697
815
|
const { verifyPlaylist } = verifier;
|
|
698
816
|
const verifyResult = verifyPlaylist(playlist);
|
|
699
817
|
if (!verifyResult.valid) {
|
|
700
|
-
|
|
701
|
-
if (verifyResult.details && verifyResult.details.length > 0) {
|
|
702
|
-
console.log(chalk_1.default.yellow('\n Validation errors:'));
|
|
703
|
-
verifyResult.details.forEach((detail) => {
|
|
704
|
-
console.log(chalk_1.default.yellow(` • ${detail.path}: ${detail.message}`));
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
console.log(chalk_1.default.yellow('\n Use --skip-verify to send anyway (not recommended)\n'));
|
|
818
|
+
printPlaylistVerificationFailure(verifyResult, `source: ${playlistResult.source}`);
|
|
708
819
|
process.exit(1);
|
|
709
820
|
}
|
|
710
821
|
console.log(chalk_1.default.green('✓ Verified\n'));
|
|
@@ -872,7 +983,7 @@ program
|
|
|
872
983
|
outputPath: options.output,
|
|
873
984
|
});
|
|
874
985
|
if (result && result.playlist) {
|
|
875
|
-
console.log(chalk_1.default.green('\nPlaylist
|
|
986
|
+
console.log(chalk_1.default.green('\nPlaylist saved'));
|
|
876
987
|
console.log(chalk_1.default.dim(` Title: ${result.playlist.title}`));
|
|
877
988
|
console.log(chalk_1.default.dim(` Items: ${result.playlist.items?.length || 0}`));
|
|
878
989
|
console.log(chalk_1.default.dim(` Output: ${options.output}\n`));
|
|
@@ -943,4 +1054,68 @@ program
|
|
|
943
1054
|
process.exit(1);
|
|
944
1055
|
}
|
|
945
1056
|
});
|
|
1057
|
+
program
|
|
1058
|
+
.command('ssh')
|
|
1059
|
+
.description('Enable or disable SSH access on an FF1 device')
|
|
1060
|
+
.argument('<action>', 'Action: enable or disable')
|
|
1061
|
+
.option('-d, --device <name>', 'Device name (uses first device if not specified)')
|
|
1062
|
+
.option('--pubkey <path>', 'SSH public key file (required for enable)')
|
|
1063
|
+
.option('--ttl <duration>', 'Auto-disable after duration (e.g. 30m, 2h, 900s)')
|
|
1064
|
+
.action(async (action, options) => {
|
|
1065
|
+
try {
|
|
1066
|
+
const normalizedAction = action.trim().toLowerCase();
|
|
1067
|
+
if (normalizedAction !== 'enable' && normalizedAction !== 'disable') {
|
|
1068
|
+
console.error(chalk_1.default.red('\nUnknown action:'), action);
|
|
1069
|
+
console.log(chalk_1.default.yellow('Available actions: enable, disable\n'));
|
|
1070
|
+
process.exit(1);
|
|
1071
|
+
}
|
|
1072
|
+
const isEnable = normalizedAction === 'enable';
|
|
1073
|
+
let publicKey;
|
|
1074
|
+
if (isEnable) {
|
|
1075
|
+
if (!options.pubkey) {
|
|
1076
|
+
console.error(chalk_1.default.red('\nPublic key is required to enable SSH'));
|
|
1077
|
+
console.log(chalk_1.default.yellow('Use: ff1 ssh enable --pubkey ~/.ssh/id_ed25519.pub\n'));
|
|
1078
|
+
process.exit(1);
|
|
1079
|
+
}
|
|
1080
|
+
publicKey = await readPublicKeyFile(options.pubkey);
|
|
1081
|
+
}
|
|
1082
|
+
let ttlSeconds;
|
|
1083
|
+
if (options.ttl) {
|
|
1084
|
+
ttlSeconds = parseTtlSeconds(options.ttl);
|
|
1085
|
+
}
|
|
1086
|
+
const { sendSshAccessCommand } = await Promise.resolve().then(() => __importStar(require('./src/utilities/ssh-access')));
|
|
1087
|
+
const result = await sendSshAccessCommand({
|
|
1088
|
+
enabled: isEnable,
|
|
1089
|
+
deviceName: options.device,
|
|
1090
|
+
publicKey,
|
|
1091
|
+
ttlSeconds,
|
|
1092
|
+
});
|
|
1093
|
+
if (result.success) {
|
|
1094
|
+
console.log(chalk_1.default.green(`SSH ${isEnable ? 'enabled' : 'disabled'}`));
|
|
1095
|
+
if (result.deviceName) {
|
|
1096
|
+
console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
|
|
1097
|
+
}
|
|
1098
|
+
if (result.device) {
|
|
1099
|
+
console.log(chalk_1.default.dim(` Host: ${result.device}`));
|
|
1100
|
+
}
|
|
1101
|
+
if (result.response && typeof result.response === 'object') {
|
|
1102
|
+
const expiresAt = result.response.expiresAt;
|
|
1103
|
+
if (expiresAt) {
|
|
1104
|
+
console.log(chalk_1.default.dim(` Expires: ${expiresAt}`));
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
console.log();
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
console.error(chalk_1.default.red('\nSSH request failed:'), result.error);
|
|
1111
|
+
if (result.details) {
|
|
1112
|
+
console.error(chalk_1.default.dim(` Details: ${result.details}`));
|
|
1113
|
+
}
|
|
1114
|
+
process.exit(1);
|
|
1115
|
+
}
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
1118
|
+
process.exit(1);
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
946
1121
|
program.parse();
|
|
@@ -7,6 +7,18 @@ const registry = require('./registry');
|
|
|
7
7
|
function sleep(ms) {
|
|
8
8
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
9
|
}
|
|
10
|
+
function stableStringify(value) {
|
|
11
|
+
if (value === null || typeof value !== 'object') {
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(value)) {
|
|
15
|
+
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
|
16
|
+
}
|
|
17
|
+
const keys = Object.keys(value).sort();
|
|
18
|
+
return `{${keys
|
|
19
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
20
|
+
.join(',')}}`;
|
|
21
|
+
}
|
|
10
22
|
async function createCompletionWithRetry(client, requestParams, maxRetries = 0) {
|
|
11
23
|
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
12
24
|
try {
|
|
@@ -456,8 +468,9 @@ KEY RULES
|
|
|
456
468
|
- The registry system handles full objects internally
|
|
457
469
|
|
|
458
470
|
OUTPUT RULES
|
|
471
|
+
- Do NOT print JSON arguments.
|
|
459
472
|
- Before each function call, print exactly one sentence: "→ I'm …" describing the action.
|
|
460
|
-
-
|
|
473
|
+
- Call the function ONLY via tool calls and do not include tool names, JSON, or arguments in assistant content.
|
|
461
474
|
- No chain‑of‑thought or extra narration; keep public output minimal.
|
|
462
475
|
|
|
463
476
|
STOPPING CONDITIONS
|
|
@@ -529,6 +542,7 @@ async function buildPlaylistWithAI(params, options = {}) {
|
|
|
529
542
|
let collectedItems = [];
|
|
530
543
|
let verificationFailures = 0;
|
|
531
544
|
let sentToDevice = false;
|
|
545
|
+
const queryRequirementCache = new Map();
|
|
532
546
|
const maxIterations = 20;
|
|
533
547
|
const maxVerificationRetries = 3;
|
|
534
548
|
while (iterationCount < maxIterations) {
|
|
@@ -626,10 +640,32 @@ async function buildPlaylistWithAI(params, options = {}) {
|
|
|
626
640
|
continue; // Go to next iteration
|
|
627
641
|
}
|
|
628
642
|
}
|
|
643
|
+
const contentText = message.content || '';
|
|
644
|
+
const looksLikeToolAttempt = !message.tool_calls &&
|
|
645
|
+
contentText &&
|
|
646
|
+
(contentText.includes("→ I'm") ||
|
|
647
|
+
contentText.includes('"requirement"') ||
|
|
648
|
+
contentText.trim().startsWith('{'));
|
|
649
|
+
if (looksLikeToolAttempt && iterationCount < maxIterations - 1) {
|
|
650
|
+
if (verbose) {
|
|
651
|
+
console.log(chalk.yellow('AI returned tool arguments in text. Forcing function call with a system reminder.'));
|
|
652
|
+
}
|
|
653
|
+
messages.push(message);
|
|
654
|
+
messages.push({
|
|
655
|
+
role: 'system',
|
|
656
|
+
content: 'CRITICAL: You MUST call the required function via tool_calls. Do not output JSON or arguments in plain text. Call the function now.',
|
|
657
|
+
});
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
629
660
|
messages.push(message);
|
|
630
|
-
//
|
|
661
|
+
// Only print non-json assistant content while keeping function-call noise low.
|
|
631
662
|
if (message.content) {
|
|
632
|
-
|
|
663
|
+
const trimmed = message.content.trim();
|
|
664
|
+
const hasToolCalls = message.tool_calls && message.tool_calls.length > 0;
|
|
665
|
+
const isJson = trimmed.startsWith('{') || trimmed.startsWith('[');
|
|
666
|
+
if (!isJson && (verbose || !hasToolCalls)) {
|
|
667
|
+
console.log(chalk.cyan(trimmed));
|
|
668
|
+
}
|
|
633
669
|
}
|
|
634
670
|
if (verbose) {
|
|
635
671
|
console.log(chalk.dim(`\nIteration ${iterationCount}:`));
|
|
@@ -647,12 +683,33 @@ async function buildPlaylistWithAI(params, options = {}) {
|
|
|
647
683
|
console.log(chalk.dim(` Input: ${JSON.stringify(args, null, 2).split('\n').join('\n ')}`));
|
|
648
684
|
}
|
|
649
685
|
try {
|
|
650
|
-
|
|
686
|
+
let result;
|
|
687
|
+
let usedCache = false;
|
|
688
|
+
if (functionName === 'query_requirement') {
|
|
689
|
+
const cacheKey = stableStringify({
|
|
690
|
+
requirement: args.requirement,
|
|
691
|
+
duration: args.duration,
|
|
692
|
+
});
|
|
693
|
+
if (queryRequirementCache.has(cacheKey)) {
|
|
694
|
+
result = queryRequirementCache.get(cacheKey);
|
|
695
|
+
usedCache = true;
|
|
696
|
+
if (verbose) {
|
|
697
|
+
console.log(chalk.dim(' ↺ Using cached result for duplicate query_requirement'));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
result = await executeFunction(functionName, args);
|
|
702
|
+
queryRequirementCache.set(cacheKey, result);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
result = await executeFunction(functionName, args);
|
|
707
|
+
}
|
|
651
708
|
if (verbose) {
|
|
652
709
|
console.log(chalk.dim(` Output: ${JSON.stringify(result, null, 2).split('\n').join('\n ')}`));
|
|
653
710
|
}
|
|
654
711
|
// Track collected item IDs from query_requirement
|
|
655
|
-
if (functionName === 'query_requirement' && Array.isArray(result)) {
|
|
712
|
+
if (functionName === 'query_requirement' && Array.isArray(result) && !usedCache) {
|
|
656
713
|
// Result now contains minimal item objects with id field
|
|
657
714
|
const itemIds = result.map((item) => item.id).filter((id) => id);
|
|
658
715
|
collectedItems = collectedItems.concat(itemIds); // Now storing IDs, not full items
|