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 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, config, and usage: `./docs/README.md`
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
 
@@ -1,14 +1,14 @@
1
1
  {
2
- "defaultModel": "gemini",
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-4-fast-non-reasoning",
7
+ "model": "grok-beta",
8
8
  "availableModels": [
9
- "grok-4-fast-reasoning",
10
- "grok-4-fast-non-reasoning",
11
- "grok-code-fast-1"
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
- "gpt": {
19
+ "chatgpt": {
20
20
  "apiKey": "YOUR_OPENAI_API_KEY_FROM_PLATFORM_OPENAI_COM",
21
21
  "baseURL": "https://api.openai.com/v1",
22
- "model": "gpt-4o-mini",
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": "YOUR_ED25519_PRIVATE_KEY_BASE64_ENCODED_RUN_NODE_INDEX_JS_GENERATE_KEYS"
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 created'));
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 (rawDeviceId) {
273
- const looksLikeHost = rawDeviceId.includes('.') ||
274
- rawDeviceId.includes(':') ||
275
- rawDeviceId.startsWith('http');
276
- if (looksLikeHost) {
277
- hostValue = normalizeDeviceHost(rawDeviceId);
278
- }
279
- else {
280
- const deviceId = rawDeviceId.startsWith('ff1-') ? rawDeviceId : `ff1-${rawDeviceId}`;
281
- hostValue = normalizeDeviceHost(`${deviceId}.local`);
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 rawName = existingDevice?.name || 'ff1';
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 created'));
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
- try {
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
- try {
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
- console.error(chalk_1.default.red('\nPlaylist verification failed:'), verifyResult.error);
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 file to an FF1 device')
684
- .argument('<file>', 'Path to the playlist 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
- // Read the playlist file
691
- const content = await fs_1.promises.readFile(file, 'utf-8');
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('Verify playlist'));
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
- console.error(chalk_1.default.red('\nPlaylist verification failed:'), verifyResult.error);
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 created'));
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
- - Then call exactly one function with JSON arguments.
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
- // Always print AI content when present
661
+ // Only print non-json assistant content while keeping function-call noise low.
631
662
  if (message.content) {
632
- console.log(chalk.cyan(message.content));
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
- const result = await executeFunction(functionName, args);
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