@yu_robotics/remote-cli-router 1.0.1 → 1.0.4

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.
@@ -49,8 +49,14 @@ class FeishuLongConnHandler {
49
49
  connectionHub = null;
50
50
  appId;
51
51
  appSecret;
52
- // Feishu message size limit (4000 chars per message)
52
+ // Feishu message size limit (4000 chars per message) - DEPRECATED for Card 2.0
53
53
  FEISHU_MESSAGE_LIMIT = 4000;
54
+ // Feishu Card 2.0 limits (from official docs)
55
+ // Note: Using conservative limits - official says 200, we use 150 with proper recursive counting
56
+ // The 150 limit leaves a 50-node safety buffer while still being practical
57
+ CARD_ELEMENT_LIMIT = 150; // Conservative: 150 tagged nodes per card (official: 200)
58
+ CARD_DATA_SIZE_LIMIT = 3000000; // Max 3MB (3,000,000 chars) for data field
59
+ CARD_SIZE_BUFFER = 100000; // Safety buffer: use 2.9MB instead of 3MB
54
60
  // Track message chains: messageId -> [messageId1, messageId2, ...]
55
61
  messageChains = new Map();
56
62
  // Track the last processed text length for each message chain
@@ -157,6 +163,9 @@ class FeishuLongConnHandler {
157
163
  case '/unbind':
158
164
  await this.handleUnbindCommand(openId, messageId);
159
165
  break;
166
+ case '/device':
167
+ await this.handleDeviceCommand(openId, messageId, parts.slice(1));
168
+ break;
160
169
  case '/help':
161
170
  await this.handleHelpCommand(openId, messageId);
162
171
  break;
@@ -178,14 +187,20 @@ class FeishuLongConnHandler {
178
187
  await this.replyToMessage(messageId, '❌ You have not bound a device yet, please send /bind <binding-code> to bind first');
179
188
  return;
180
189
  }
190
+ // Get active device
191
+ const activeDevice = await this.bindingManager.getActiveDevice(openId);
192
+ if (!activeDevice) {
193
+ await this.replyToMessage(messageId, '❌ No active device found. Please use /device list to view your devices');
194
+ return;
195
+ }
181
196
  // Check if ConnectionHub is available
182
197
  if (!this.connectionHub) {
183
198
  await this.replyToMessage(messageId, '❌ Server error: ConnectionHub not initialized');
184
199
  return;
185
200
  }
186
201
  // Check if device is online
187
- if (!this.connectionHub.isDeviceOnline(binding.deviceId)) {
188
- await this.replyToMessage(messageId, `❌ Device ${binding.deviceName} is currently offline, please ensure the device is started and connected to the server`);
202
+ if (!this.connectionHub.isDeviceOnline(activeDevice.deviceId)) {
203
+ await this.replyToMessage(messageId, `❌ Device ${activeDevice.deviceName} is currently offline, please ensure the device is started and connected to the server`);
189
204
  return;
190
205
  }
191
206
  console.log(`[FeishuHandler] Passing through slash command: ${command}`);
@@ -195,10 +210,10 @@ class FeishuLongConnHandler {
195
210
  const feishuMessageId = await this.sendStreamingStart(openId, `🤔 Executing ${command}...`);
196
211
  console.log(`[FeishuHandler] Created card ${feishuMessageId} for slash command ${commandMessageId}`);
197
212
  if (this.onStartStreaming) {
198
- this.onStartStreaming(commandMessageId, openId, feishuMessageId, binding.deviceId);
213
+ this.onStartStreaming(commandMessageId, openId, feishuMessageId, activeDevice.deviceId);
199
214
  }
200
215
  // Send slash command to device - the client will execute it locally
201
- const success = await this.connectionHub.sendToDevice(binding.deviceId, {
216
+ const success = await this.connectionHub.sendToDevice(activeDevice.deviceId, {
202
217
  type: types_1.MessageType.COMMAND,
203
218
  messageId: commandMessageId,
204
219
  timestamp: Date.now(),
@@ -226,10 +241,21 @@ class FeishuLongConnHandler {
226
241
  await this.replyToMessage(messageId, '❌ Binding code is invalid or expired, please generate a new one');
227
242
  return;
228
243
  }
244
+ // Check if device is already bound
245
+ const existingDevices = await this.bindingManager.getUserDevices(openId);
246
+ const alreadyBound = existingDevices.some(d => d.deviceId === bindingCode.deviceId);
247
+ if (alreadyBound) {
248
+ await this.replyToMessage(messageId, '❌ This device is already bound to your account');
249
+ return;
250
+ }
229
251
  // Bind user
230
252
  const deviceName = 'Device'; // Will be updated by client later
231
253
  await this.bindingManager.bindUser(openId, bindingCode.deviceId, deviceName);
232
- await this.replyToMessage(messageId, `✅ Binding successful!\n\nDevice ID: ${bindingCode.deviceId}\n\nYou can now control your device through Feishu.`);
254
+ const isFirstDevice = existingDevices.length === 0;
255
+ const statusNote = isFirstDevice
256
+ ? '\n\n📱 This is your first device and will be set as active.'
257
+ : '\n\n📱 Use /device switch to activate this device.';
258
+ await this.replyToMessage(messageId, `✅ Binding successful!\n\nDevice ID: ${bindingCode.deviceId}\n\nYou can now control your device through Feishu.${statusNote}\n\nUse /device list to view all your devices.`);
233
259
  }
234
260
  catch (error) {
235
261
  console.error('Error binding user:', error);
@@ -246,9 +272,24 @@ class FeishuLongConnHandler {
246
272
  await this.replyToMessage(messageId, '❌ You have not bound a device yet, please send /bind <binding-code> to bind first');
247
273
  return;
248
274
  }
249
- const isOnline = this.connectionHub?.isDeviceOnline(binding.deviceId) || false;
250
- const status = isOnline ? '🟢 Online' : '🔴 Offline';
251
- const message = `📊 Device Status\n\nDevice Name: ${binding.deviceName}\nDevice ID: ${binding.deviceId}\nStatus: ${status}\nBinding Time: ${new Date(binding.boundAt).toLocaleString('en-US')}`;
275
+ const devices = binding.devices;
276
+ if (devices.length === 0) {
277
+ await this.replyToMessage(messageId, '❌ No devices found');
278
+ return;
279
+ }
280
+ // Build status message
281
+ let message = `📊 Device Status\n\n`;
282
+ for (const device of devices) {
283
+ const isOnline = this.connectionHub?.isDeviceOnline(device.deviceId) || false;
284
+ const status = isOnline ? '🟢 Online' : '🔴 Offline';
285
+ const activeIndicator = device.isActive ? ' ⭐ ACTIVE' : '';
286
+ message += `\n**${device.deviceName}**${activeIndicator}\n`;
287
+ message += `Device ID: ${device.deviceId}\n`;
288
+ message += `Status: ${status}\n`;
289
+ message += `Bound: ${new Date(device.boundAt).toLocaleString('en-US')}\n`;
290
+ message += `Last Active: ${new Date(device.lastActiveAt).toLocaleString('en-US')}\n`;
291
+ }
292
+ message += `\n\nTotal Devices: ${devices.length}`;
252
293
  await this.replyToMessage(messageId, message);
253
294
  }
254
295
  catch (error) {
@@ -258,6 +299,8 @@ class FeishuLongConnHandler {
258
299
  }
259
300
  /**
260
301
  * Handle unbind command
302
+ * Usage: /unbind or /unbind all (unbind all devices)
303
+ * For unbinding specific device, use /device unbind <device-id>
261
304
  */
262
305
  async handleUnbindCommand(openId, messageId) {
263
306
  try {
@@ -266,14 +309,188 @@ class FeishuLongConnHandler {
266
309
  await this.replyToMessage(messageId, '❌ You have not bound a device yet');
267
310
  return;
268
311
  }
312
+ const deviceCount = binding.devices.length;
313
+ // Unbind all devices
269
314
  await this.bindingManager.unbindUser(openId);
270
- await this.replyToMessage(messageId, `✅ Device ${binding.deviceName} has been unbound`);
315
+ await this.replyToMessage(messageId, `✅ Successfully unbound ${deviceCount} device(s)`);
271
316
  }
272
317
  catch (error) {
273
318
  console.error('Error handling unbind command:', error);
274
319
  await this.replyToMessage(messageId, '❌ Unbinding failed, please try again later');
275
320
  }
276
321
  }
322
+ /**
323
+ * Handle device command
324
+ * Usage:
325
+ * /device - List all bound devices (same as /device list)
326
+ * /device list - List all bound devices
327
+ * /device switch <device-id|index> - Switch active device (by ID or index number)
328
+ * /device <device-id|index> - Quick switch to device (by ID or index number)
329
+ * /device unbind <device-id|index> - Unbind a specific device (by ID or index number)
330
+ */
331
+ async handleDeviceCommand(openId, messageId, args) {
332
+ try {
333
+ const binding = await this.bindingManager.getUserBinding(openId);
334
+ if (!binding) {
335
+ await this.replyToMessage(messageId, '❌ You have not bound a device yet, please send /bind <binding-code> to bind first');
336
+ return;
337
+ }
338
+ // No args - show device list
339
+ if (args.length === 0) {
340
+ await this.handleDeviceList(openId, messageId, binding);
341
+ return;
342
+ }
343
+ const subcommand = args[0]?.toLowerCase();
344
+ switch (subcommand) {
345
+ case 'list':
346
+ await this.handleDeviceList(openId, messageId, binding);
347
+ break;
348
+ case 'switch':
349
+ if (args.length < 2) {
350
+ await this.replyToMessage(messageId, '❌ Please provide device ID or index, format: /device switch <device-id-or-index>');
351
+ return;
352
+ }
353
+ await this.handleDeviceSwitch(openId, messageId, args[1], binding);
354
+ break;
355
+ case 'unbind':
356
+ if (args.length < 2) {
357
+ await this.replyToMessage(messageId, '❌ Please provide device ID or index, format: /device unbind <device-id-or-index>');
358
+ return;
359
+ }
360
+ await this.handleDeviceUnbind(openId, messageId, args[1], binding);
361
+ break;
362
+ default:
363
+ // If the argument looks like a number (index) or device ID, treat it as a quick switch
364
+ await this.handleDeviceSwitch(openId, messageId, args[0], binding);
365
+ }
366
+ }
367
+ catch (error) {
368
+ console.error('Error handling device command:', error);
369
+ await this.replyToMessage(messageId, '❌ Error processing device command, please try again later');
370
+ }
371
+ }
372
+ /**
373
+ * Handle /device list
374
+ */
375
+ async handleDeviceList(openId, messageId, binding) {
376
+ const devices = binding.devices;
377
+ if (devices.length === 0) {
378
+ await this.replyToMessage(messageId, '❌ No devices found');
379
+ return;
380
+ }
381
+ let message = `📱 Your Devices (${devices.length})\n\n`;
382
+ for (let i = 0; i < devices.length; i++) {
383
+ const device = devices[i];
384
+ const isOnline = this.connectionHub?.isDeviceOnline(device.deviceId) || false;
385
+ const status = isOnline ? '🟢 Online' : '🔴 Offline';
386
+ const activeIndicator = device.isActive ? ' ⭐ ACTIVE' : '';
387
+ message += `${i + 1}. **${device.deviceName}**${activeIndicator}\n`;
388
+ message += ` ID: \`${device.deviceId}\`\n`;
389
+ message += ` Status: ${status}\n`;
390
+ message += ` Bound: ${new Date(device.boundAt).toLocaleString('en-US')}\n\n`;
391
+ }
392
+ message += `\n💡 Quick switch: /device <index> or /device <device-id>`;
393
+ message += `\n Example: /device 1 or /device switch 1`;
394
+ await this.replyToMessage(messageId, message);
395
+ }
396
+ /**
397
+ * Resolve device identifier (ID or index) to device ID
398
+ * @returns Resolved device ID or null if not found
399
+ */
400
+ resolveDeviceIdentifier(identifier, binding) {
401
+ // Try to parse as index (1-based)
402
+ const index = parseInt(identifier, 10);
403
+ if (!isNaN(index) && index > 0 && index <= binding.devices.length) {
404
+ return binding.devices[index - 1].deviceId;
405
+ }
406
+ // Treat as device ID - check if it exists
407
+ const device = binding.devices.find((d) => d.deviceId === identifier);
408
+ if (device) {
409
+ return device.deviceId;
410
+ }
411
+ return null;
412
+ }
413
+ /**
414
+ * Handle /device switch <device-id-or-index>
415
+ * Also handles quick switch: /device <device-id-or-index>
416
+ */
417
+ async handleDeviceSwitch(openId, messageId, identifier, binding) {
418
+ try {
419
+ // Get binding if not provided
420
+ const userBinding = binding || await this.bindingManager.getUserBinding(openId);
421
+ if (!userBinding) {
422
+ await this.replyToMessage(messageId, '❌ You have not bound a device yet');
423
+ return;
424
+ }
425
+ // Resolve identifier to device ID
426
+ const deviceId = this.resolveDeviceIdentifier(identifier, userBinding);
427
+ if (!deviceId) {
428
+ await this.replyToMessage(messageId, `❌ Device "${identifier}" not found. Use /device to see available devices and their indices.`);
429
+ return;
430
+ }
431
+ const result = await this.bindingManager.switchActiveDevice(openId, deviceId);
432
+ if (!result) {
433
+ await this.replyToMessage(messageId, '❌ Device switch failed');
434
+ return;
435
+ }
436
+ const device = await this.bindingManager.getActiveDevice(openId);
437
+ if (!device) {
438
+ await this.replyToMessage(messageId, '❌ Failed to get active device after switch');
439
+ return;
440
+ }
441
+ await this.replyToMessage(messageId, `✅ Switched to device: **${device.deviceName}**\n\nDevice ID: \`${device.deviceId}\``);
442
+ }
443
+ catch (error) {
444
+ console.error('Error switching device:', error);
445
+ await this.replyToMessage(messageId, '❌ Failed to switch device, please try again later');
446
+ }
447
+ }
448
+ /**
449
+ * Handle /device unbind <device-id-or-index>
450
+ */
451
+ async handleDeviceUnbind(openId, messageId, identifier, binding) {
452
+ try {
453
+ // Get binding if not provided
454
+ const userBinding = binding || await this.bindingManager.getUserBinding(openId);
455
+ if (!userBinding) {
456
+ await this.replyToMessage(messageId, '❌ You have not bound a device yet');
457
+ return;
458
+ }
459
+ // Resolve identifier to device ID
460
+ const deviceId = this.resolveDeviceIdentifier(identifier, userBinding);
461
+ if (!deviceId) {
462
+ await this.replyToMessage(messageId, `❌ Device "${identifier}" not found. Use /device to see available devices and their indices.`);
463
+ return;
464
+ }
465
+ const device = userBinding.devices.find((d) => d.deviceId === deviceId);
466
+ if (!device) {
467
+ await this.replyToMessage(messageId, '❌ Device not found');
468
+ return;
469
+ }
470
+ const wasActive = device.isActive;
471
+ const result = await this.bindingManager.unbindDevice(openId, deviceId);
472
+ if (!result) {
473
+ await this.replyToMessage(messageId, '❌ Failed to unbind device');
474
+ return;
475
+ }
476
+ let responseMessage = `✅ Device **${device.deviceName}** has been unbound`;
477
+ // If we unbound the active device, inform about the new active device
478
+ if (wasActive) {
479
+ const newActiveDevice = await this.bindingManager.getActiveDevice(openId);
480
+ if (newActiveDevice) {
481
+ responseMessage += `\n\n📱 New active device: **${newActiveDevice.deviceName}**`;
482
+ }
483
+ else {
484
+ responseMessage += `\n\n⚠️ No devices remaining. Use /bind to add a new device.`;
485
+ }
486
+ }
487
+ await this.replyToMessage(messageId, responseMessage);
488
+ }
489
+ catch (error) {
490
+ console.error('Error unbinding device:', error);
491
+ await this.replyToMessage(messageId, '❌ Failed to unbind device, please try again later');
492
+ }
493
+ }
277
494
  /**
278
495
  * Handle help command
279
496
  */
@@ -281,14 +498,30 @@ class FeishuLongConnHandler {
281
498
  const helpMessage = `📖 Feishu Remote Control Help
282
499
 
283
500
  Available commands:
284
- /bind <binding-code> - Bind your device
285
- /status - View device status
286
- /unbind - Unbind device
501
+ /bind <binding-code> - Bind a new device
502
+ /status - View all device statuses
503
+ /unbind - Unbind all devices
504
+ /device - List all your devices
505
+ /device list - List all your devices
506
+ /device switch <device-id-or-index> - Switch active device
507
+ /device <device-id-or-index> - Quick switch to device
508
+ /device unbind <device-id-or-index> - Unbind a specific device
287
509
  /help - Show help information
288
510
 
289
- Regular messages will be sent directly to your device for execution.
511
+ Regular messages will be sent to your active device for execution.
512
+
513
+ Multi-device support:
514
+ • You can bind multiple devices to your account
515
+ • Only one device is active at a time
516
+ • Commands are sent to the active device
517
+ • Use /device or /device list to see your devices
518
+ • Switch by index: /device 1 or /device switch 2
519
+ • Switch by ID: /device <device-id>
290
520
 
291
521
  Examples:
522
+ • "/device" - List all devices
523
+ • "/device 1" - Switch to device #1
524
+ • "/device switch 2" - Switch to device #2
292
525
  • "List files in current directory"
293
526
  • "Run tests"
294
527
  • "View recent git commits"`;
@@ -305,14 +538,20 @@ Examples:
305
538
  await this.replyToMessage(messageId, '❌ You have not bound a device yet, please send /bind <binding-code> to bind first');
306
539
  return;
307
540
  }
541
+ // Get active device
542
+ const activeDevice = await this.bindingManager.getActiveDevice(openId);
543
+ if (!activeDevice) {
544
+ await this.replyToMessage(messageId, '❌ No active device found. Please use /device list to view your devices');
545
+ return;
546
+ }
308
547
  // Check if ConnectionHub is available
309
548
  if (!this.connectionHub) {
310
549
  await this.replyToMessage(messageId, '❌ Server error: ConnectionHub not initialized');
311
550
  return;
312
551
  }
313
552
  // Check if device is online
314
- if (!this.connectionHub.isDeviceOnline(binding.deviceId)) {
315
- await this.replyToMessage(messageId, `❌ Device ${binding.deviceName} is currently offline, please ensure the device is started and connected to the server`);
553
+ if (!this.connectionHub.isDeviceOnline(activeDevice.deviceId)) {
554
+ await this.replyToMessage(messageId, `❌ Device ${activeDevice.deviceName} is currently offline, please ensure the device is started and connected to the server`);
316
555
  return;
317
556
  }
318
557
  // Generate message ID first
@@ -323,10 +562,10 @@ Examples:
323
562
  const feishuMessageId = await this.sendStreamingStart(openId, '🤔 Processing...');
324
563
  console.log(`[FeishuHandler] Created card ${feishuMessageId} for command ${commandMessageId}`);
325
564
  if (this.onStartStreaming) {
326
- this.onStartStreaming(commandMessageId, openId, feishuMessageId, binding.deviceId);
565
+ this.onStartStreaming(commandMessageId, openId, feishuMessageId, activeDevice.deviceId);
327
566
  }
328
567
  // Send command to device
329
- const success = await this.connectionHub.sendToDevice(binding.deviceId, {
568
+ const success = await this.connectionHub.sendToDevice(activeDevice.deviceId, {
330
569
  type: types_1.MessageType.COMMAND,
331
570
  messageId: commandMessageId,
332
571
  timestamp: Date.now(),
@@ -474,6 +713,192 @@ Examples:
474
713
  }
475
714
  return chunks;
476
715
  }
716
+ /**
717
+ * Split Card 2.0 elements into chunks based on Feishu limits
718
+ *
719
+ * Feishu Card 2.0 has two main limits:
720
+ * 1. Element count: Max 200 tagged nodes per card (we use 150 for safety)
721
+ * 2. Data size: Max 3,000,000 characters in the data field (JSON.stringify result)
722
+ *
723
+ * This function splits elements array into chunks that satisfy both limits,
724
+ * and adds continuation indicators between chunks for better UX.
725
+ *
726
+ * Note: Element counting uses recursive tag counting - all nodes with 'tag' property
727
+ * are counted, including nested components, text elements, and container children.
728
+ *
729
+ * @param elements Array of Feishu Card 2.0 elements
730
+ * @returns Array of element chunks, each satisfying Feishu's limits
731
+ */
732
+ splitElementsIntoChunks(elements) {
733
+ // If empty or very small, return as-is
734
+ if (elements.length === 0) {
735
+ return [elements];
736
+ }
737
+ // Check if we need to split at all
738
+ const needsSplitting = this.checkIfElementsNeedSplitting(elements);
739
+ console.log(`[FeishuHandler] Elements check: count=${elements.length}, needsSplitting=${needsSplitting}`);
740
+ if (!needsSplitting) {
741
+ return [elements];
742
+ }
743
+ const chunks = [];
744
+ let currentChunk = [];
745
+ let currentChunkSize = 0;
746
+ let currentChunkTaggedNodes = 0;
747
+ // Reserve elements for continuation indicators (they add ~2 elements and ~200 bytes)
748
+ const continuationIndicatorSize = 200; // Approximate size of indicator element
749
+ const continuationIndicatorCount = 2; // Maximum 2 indicators per chunk (start + end)
750
+ for (let i = 0; i < elements.length; i++) {
751
+ const element = elements[i];
752
+ const elementSize = JSON.stringify(element).length;
753
+ const elementTaggedNodes = this.countTaggedNodes(element);
754
+ // Check if adding this element would exceed limits
755
+ // Reserve space for continuation indicators
756
+ const wouldExceedElementLimit = currentChunkTaggedNodes + elementTaggedNodes > (this.CARD_ELEMENT_LIMIT - continuationIndicatorCount);
757
+ const wouldExceedSizeLimit = currentChunkSize + elementSize + continuationIndicatorSize >
758
+ (this.CARD_DATA_SIZE_LIMIT - this.CARD_SIZE_BUFFER);
759
+ if (currentChunk.length > 0 && (wouldExceedElementLimit || wouldExceedSizeLimit)) {
760
+ // Start a new chunk
761
+ chunks.push(currentChunk);
762
+ console.log(`[FeishuHandler] Chunk finished: ${currentChunk.length} top-level elements, ${currentChunkTaggedNodes} tagged nodes, ${currentChunkSize} bytes`);
763
+ currentChunk = [];
764
+ currentChunkSize = 0;
765
+ currentChunkTaggedNodes = 0;
766
+ }
767
+ currentChunk.push(element);
768
+ currentChunkSize += elementSize;
769
+ currentChunkTaggedNodes += elementTaggedNodes;
770
+ }
771
+ // Add the last chunk if not empty
772
+ if (currentChunk.length > 0) {
773
+ chunks.push(currentChunk);
774
+ console.log(`[FeishuHandler] Chunk finished: ${currentChunk.length} top-level elements, ${currentChunkTaggedNodes} tagged nodes, ${currentChunkSize} bytes`);
775
+ }
776
+ console.log(`[FeishuHandler] Split ${elements.length} top-level elements into ${chunks.length} chunk(s)`);
777
+ chunks.forEach((chunk, i) => {
778
+ const chunkSize = JSON.stringify({ schema: '2.0', body: { elements: chunk } }).length;
779
+ const chunkTaggedNodes = chunk.reduce((sum, el) => sum + this.countTaggedNodes(el), 0);
780
+ console.log(`[FeishuHandler] Chunk ${i + 1}: ${chunk.length} top-level, ${chunkTaggedNodes} tagged nodes, ${chunkSize} bytes`);
781
+ });
782
+ // Add continuation indicators between chunks
783
+ if (chunks.length > 1) {
784
+ for (let i = 0; i < chunks.length; i++) {
785
+ const isFirst = i === 0;
786
+ const isLast = i === chunks.length - 1;
787
+ if (!isLast) {
788
+ // Add "continued in next message" indicator at the end
789
+ chunks[i].push({
790
+ tag: 'markdown',
791
+ content: '\n\n_➡️ Continued in next message..._',
792
+ });
793
+ }
794
+ if (!isFirst) {
795
+ // Add "continued from previous message" indicator at the start
796
+ chunks[i].unshift({
797
+ tag: 'markdown',
798
+ content: '_⬅️ Continued from previous message..._\n\n',
799
+ });
800
+ }
801
+ }
802
+ }
803
+ return chunks;
804
+ }
805
+ /**
806
+ * Recursively count all nodes with 'tag' property in the element tree
807
+ * According to Feishu docs, all nodes with 'tag' property count towards the 200 limit
808
+ *
809
+ * @param obj The object or array to count tags in
810
+ * @returns Total count of nodes with 'tag' property
811
+ */
812
+ countTaggedNodes(obj) {
813
+ if (!obj || typeof obj !== 'object') {
814
+ return 0;
815
+ }
816
+ let count = 0;
817
+ // If this object has a 'tag' property, count it
818
+ if (obj.tag) {
819
+ count = 1;
820
+ }
821
+ // Recursively count in all properties
822
+ for (const key in obj) {
823
+ if (obj.hasOwnProperty(key)) {
824
+ const value = obj[key];
825
+ if (Array.isArray(value)) {
826
+ // Recursively count in array elements
827
+ for (const item of value) {
828
+ count += this.countTaggedNodes(item);
829
+ }
830
+ }
831
+ else if (typeof value === 'object' && value !== null) {
832
+ // Recursively count in nested objects
833
+ count += this.countTaggedNodes(value);
834
+ }
835
+ }
836
+ }
837
+ return count;
838
+ }
839
+ /**
840
+ * Check if elements array needs to be split based on Feishu limits
841
+ * @param elements Array of elements to check
842
+ * @returns true if splitting is needed
843
+ */
844
+ checkIfElementsNeedSplitting(elements) {
845
+ // Safety check: ensure elements is an array
846
+ if (!Array.isArray(elements)) {
847
+ console.error('[FeishuHandler] checkIfElementsNeedSplitting received non-array:', typeof elements);
848
+ return false;
849
+ }
850
+ // Check element count limit - MUST count ALL nodes with 'tag' property recursively
851
+ const totalTaggedNodes = elements.reduce((sum, element) => sum + this.countTaggedNodes(element), 0);
852
+ console.log(`[FeishuHandler] Element count check: top-level=${elements.length}, total tagged nodes=${totalTaggedNodes}, limit=${this.CARD_ELEMENT_LIMIT}`);
853
+ if (totalTaggedNodes > this.CARD_ELEMENT_LIMIT) {
854
+ console.log(`[FeishuHandler] Total tagged nodes (${totalTaggedNodes}) exceeds limit (${this.CARD_ELEMENT_LIMIT})`);
855
+ return true;
856
+ }
857
+ // Check data size limit
858
+ const cardData = {
859
+ schema: '2.0',
860
+ body: { elements },
861
+ };
862
+ const jsonSize = JSON.stringify(cardData).length;
863
+ const sizeLimit = this.CARD_DATA_SIZE_LIMIT - this.CARD_SIZE_BUFFER;
864
+ console.log(`[FeishuHandler] Card data size: ${jsonSize} bytes, limit: ${sizeLimit} bytes`);
865
+ // Use buffer for safety (2.9MB instead of 3MB)
866
+ if (jsonSize > sizeLimit) {
867
+ console.log(`[FeishuHandler] Data size (${jsonSize}) exceeds limit (${sizeLimit})`);
868
+ return true;
869
+ }
870
+ return false;
871
+ }
872
+ /**
873
+ * Create a continuation card message
874
+ * Used when elements need to be split across multiple cards
875
+ *
876
+ * @param openId User's open_id to send the message to
877
+ * @param elements Array of Feishu Card 2.0 elements
878
+ * @returns The new message ID, or null on error
879
+ */
880
+ async createContinuationCard(openId, elements) {
881
+ try {
882
+ const result = await this.client.im.message.create({
883
+ params: { receive_id_type: 'open_id' },
884
+ data: {
885
+ receive_id: openId,
886
+ msg_type: 'interactive',
887
+ content: JSON.stringify({
888
+ schema: '2.0',
889
+ body: {
890
+ elements,
891
+ },
892
+ }),
893
+ },
894
+ });
895
+ return result.data?.message_id || null;
896
+ }
897
+ catch (error) {
898
+ console.error('Failed to create continuation card:', error?.message || error);
899
+ return null;
900
+ }
901
+ }
477
902
  /**
478
903
  * Update streaming message content
479
904
  * Automatically creates new messages if content exceeds Feishu's size limit
@@ -498,19 +923,90 @@ Examples:
498
923
  chain = [messageId]; // First message in chain
499
924
  this.messageChains.set(messageId, chain);
500
925
  }
501
- // For now, just update the single message with all elements
502
- // TODO: In the future, implement message chaining if elements become too large
926
+ // Split elements into chunks based on Feishu Card 2.0 limits
927
+ const chunks = this.splitElementsIntoChunks(elements);
928
+ console.log(`[FeishuHandler] Need ${chunks.length} card(s), currently have ${chain.length} card(s)`);
929
+ // Update the first message with the first chunk
503
930
  await this.client.im.message.patch({
504
- path: { message_id: messageId },
931
+ path: { message_id: chain[0] },
505
932
  data: {
506
933
  content: JSON.stringify({
507
934
  schema: '2.0',
508
935
  body: {
509
- elements,
936
+ elements: chunks[0],
510
937
  },
511
938
  }),
512
939
  },
513
940
  });
941
+ // Handle continuation chunks if needed
942
+ if (chunks.length > 1 && openId) {
943
+ const existingContinuationCards = chain.slice(1);
944
+ const neededContinuationCards = chunks.length - 1;
945
+ // Update existing continuation cards
946
+ for (let i = 0; i < Math.min(existingContinuationCards.length, neededContinuationCards); i++) {
947
+ const cardMessageId = existingContinuationCards[i];
948
+ const chunkIndex = i + 1;
949
+ console.log(`[FeishuHandler] Updating existing continuation card ${i + 1}/${neededContinuationCards}`);
950
+ await this.client.im.message.patch({
951
+ path: { message_id: cardMessageId },
952
+ data: {
953
+ content: JSON.stringify({
954
+ schema: '2.0',
955
+ body: {
956
+ elements: chunks[chunkIndex],
957
+ },
958
+ }),
959
+ },
960
+ });
961
+ }
962
+ // Create new continuation cards if needed
963
+ if (neededContinuationCards > existingContinuationCards.length) {
964
+ console.log(`[FeishuHandler] Creating ${neededContinuationCards - existingContinuationCards.length} new continuation card(s)`);
965
+ for (let i = existingContinuationCards.length; i < neededContinuationCards; i++) {
966
+ const chunkIndex = i + 1;
967
+ const newMessageId = await this.createContinuationCard(openId, chunks[chunkIndex]);
968
+ if (newMessageId) {
969
+ chain.push(newMessageId);
970
+ }
971
+ }
972
+ }
973
+ // Delete excess continuation cards if we need fewer cards now
974
+ if (existingContinuationCards.length > neededContinuationCards) {
975
+ const cardsToDelete = existingContinuationCards.slice(neededContinuationCards);
976
+ console.log(`[FeishuHandler] Deleting ${cardsToDelete.length} excess continuation card(s)`);
977
+ for (const cardMessageId of cardsToDelete) {
978
+ try {
979
+ await this.client.im.message.delete({
980
+ path: { message_id: cardMessageId },
981
+ });
982
+ }
983
+ catch (error) {
984
+ console.error(`Failed to delete continuation card ${cardMessageId}:`, error?.message || error);
985
+ }
986
+ }
987
+ // Update chain to remove deleted cards
988
+ chain.length = chunks.length;
989
+ }
990
+ this.messageChains.set(messageId, chain);
991
+ }
992
+ else if (chunks.length === 1 && chain.length > 1) {
993
+ // No longer need continuation cards, delete all of them
994
+ const cardsToDelete = chain.slice(1);
995
+ console.log(`[FeishuHandler] No longer need continuation cards, deleting ${cardsToDelete.length} card(s)`);
996
+ for (const cardMessageId of cardsToDelete) {
997
+ try {
998
+ await this.client.im.message.delete({
999
+ path: { message_id: cardMessageId },
1000
+ });
1001
+ }
1002
+ catch (error) {
1003
+ console.error(`Failed to delete continuation card ${cardMessageId}:`, error?.message || error);
1004
+ }
1005
+ }
1006
+ // Update chain to only keep the first message
1007
+ chain.length = 1;
1008
+ this.messageChains.set(messageId, chain);
1009
+ }
514
1010
  return true;
515
1011
  }
516
1012
  catch (error) {
@@ -527,10 +1023,10 @@ Examples:
527
1023
  * @param sessionAbbr Optional session abbreviation
528
1024
  * @param openId User's open_id for creating continuation messages
529
1025
  */
530
- async finalizeStreamingMessage(messageId, elements, sessionAbbr, openId) {
531
- return this.withMessageLock(messageId, () => this._finalizeStreamingMessage(messageId, elements, sessionAbbr, openId));
1026
+ async finalizeStreamingMessage(messageId, elements, sessionAbbr, openId, cwd) {
1027
+ return this.withMessageLock(messageId, () => this._finalizeStreamingMessage(messageId, elements, sessionAbbr, openId, cwd));
532
1028
  }
533
- async _finalizeStreamingMessage(messageId, elements, sessionAbbr, openId) {
1029
+ async _finalizeStreamingMessage(messageId, elements, sessionAbbr, openId, cwd) {
534
1030
  try {
535
1031
  // Get or initialize message chain
536
1032
  let chain = this.messageChains.get(messageId);
@@ -538,11 +1034,16 @@ Examples:
538
1034
  chain = [messageId];
539
1035
  this.messageChains.set(messageId, chain);
540
1036
  }
541
- // Build note content with session abbreviation if available
1037
+ // Build note content with session abbreviation and working directory if available
542
1038
  let noteContent = '✅ Completed';
543
1039
  if (sessionAbbr) {
544
1040
  noteContent += ` · Session: ${sessionAbbr}`;
545
1041
  }
1042
+ if (cwd) {
1043
+ // Format cwd to show ~ for home directory
1044
+ const formattedCwd = cwd.replace(process.env.HOME || '/Users', '~');
1045
+ noteContent += `\n📂 **Working Directory:** \`${formattedCwd}\``;
1046
+ }
546
1047
  // Add completion note element as markdown
547
1048
  const finalElements = [
548
1049
  ...elements,
@@ -551,19 +1052,88 @@ Examples:
551
1052
  content: noteContent,
552
1053
  },
553
1054
  ];
554
- // Update the message with final content
555
- // TODO: In the future, implement message chaining if elements become too large
1055
+ // Split elements into chunks based on Feishu Card 2.0 limits
1056
+ const chunks = this.splitElementsIntoChunks(finalElements);
1057
+ console.log(`[FeishuHandler] Finalizing: need ${chunks.length} card(s), currently have ${chain.length} card(s)`);
1058
+ // Update the first message with the first chunk
556
1059
  await this.client.im.message.patch({
557
- path: { message_id: messageId },
1060
+ path: { message_id: chain[0] },
558
1061
  data: {
559
1062
  content: JSON.stringify({
560
1063
  schema: '2.0',
561
1064
  body: {
562
- elements: finalElements,
1065
+ elements: chunks[0],
563
1066
  },
564
1067
  }),
565
1068
  },
566
1069
  });
1070
+ // Handle continuation chunks if needed
1071
+ if (chunks.length > 1 && openId) {
1072
+ const existingContinuationCards = chain.slice(1);
1073
+ const neededContinuationCards = chunks.length - 1;
1074
+ // Update existing continuation cards
1075
+ for (let i = 0; i < Math.min(existingContinuationCards.length, neededContinuationCards); i++) {
1076
+ const cardMessageId = existingContinuationCards[i];
1077
+ const chunkIndex = i + 1;
1078
+ console.log(`[FeishuHandler] Finalize: Updating existing continuation card ${i + 1}/${neededContinuationCards}`);
1079
+ await this.client.im.message.patch({
1080
+ path: { message_id: cardMessageId },
1081
+ data: {
1082
+ content: JSON.stringify({
1083
+ schema: '2.0',
1084
+ body: {
1085
+ elements: chunks[chunkIndex],
1086
+ },
1087
+ }),
1088
+ },
1089
+ });
1090
+ }
1091
+ // Create new continuation cards if needed
1092
+ if (neededContinuationCards > existingContinuationCards.length) {
1093
+ console.log(`[FeishuHandler] Finalize: Creating ${neededContinuationCards - existingContinuationCards.length} new continuation card(s)`);
1094
+ for (let i = existingContinuationCards.length; i < neededContinuationCards; i++) {
1095
+ const chunkIndex = i + 1;
1096
+ const newMessageId = await this.createContinuationCard(openId, chunks[chunkIndex]);
1097
+ if (newMessageId) {
1098
+ chain.push(newMessageId);
1099
+ }
1100
+ }
1101
+ }
1102
+ // Delete excess continuation cards if we need fewer cards now
1103
+ if (existingContinuationCards.length > neededContinuationCards) {
1104
+ const cardsToDelete = existingContinuationCards.slice(neededContinuationCards);
1105
+ console.log(`[FeishuHandler] Finalize: Deleting ${cardsToDelete.length} excess continuation card(s)`);
1106
+ for (const cardMessageId of cardsToDelete) {
1107
+ try {
1108
+ await this.client.im.message.delete({
1109
+ path: { message_id: cardMessageId },
1110
+ });
1111
+ }
1112
+ catch (error) {
1113
+ console.error(`Failed to delete continuation card ${cardMessageId}:`, error?.message || error);
1114
+ }
1115
+ }
1116
+ // Update chain to remove deleted cards
1117
+ chain.length = chunks.length;
1118
+ }
1119
+ }
1120
+ else if (chunks.length === 1 && chain.length > 1) {
1121
+ // No longer need continuation cards, delete all of them
1122
+ const cardsToDelete = chain.slice(1);
1123
+ console.log(`[FeishuHandler] Finalize: No longer need continuation cards, deleting ${cardsToDelete.length} card(s)`);
1124
+ for (const cardMessageId of cardsToDelete) {
1125
+ try {
1126
+ await this.client.im.message.delete({
1127
+ path: { message_id: cardMessageId },
1128
+ });
1129
+ }
1130
+ catch (error) {
1131
+ console.error(`Failed to delete continuation card ${cardMessageId}:`, error?.message || error);
1132
+ }
1133
+ }
1134
+ // Update chain to only keep the first message
1135
+ chain.length = 1;
1136
+ }
567
1137
  // Clean up message chain tracking
568
1138
  this.messageChains.delete(messageId);
569
1139
  this.lastProcessedLengths.delete(messageId);