sogni-gen 1.5.2 → 1.5.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/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sogni-gen
3
- version: "1.5.2"
3
+ version: "1.5.3"
4
4
  description: Generate images **and videos** using Sogni AI's decentralized network. Ask the agent to "draw", "generate", "create an image", or "make a video/animate" from a prompt or reference image.
5
5
  homepage: https://sogni.ai
6
6
  metadata:
@@ -128,6 +128,9 @@ node sogni-gen.mjs -q -o /tmp/cat.png "a cat wearing a hat"
128
128
  | `--json` | JSON output | false |
129
129
  | `--strict-size` | Do not auto-adjust i2v video size for reference resizing constraints | false |
130
130
  | `-q, --quiet` | No progress output | false |
131
+ | `--extract-last-frame <video> <image>` | Extract last frame from video (safe ffmpeg wrapper) | - |
132
+ | `--concat-videos <out> <clips...>` | Concatenate video clips (safe ffmpeg wrapper) | - |
133
+ | `--list-media [type]` | List recent inbound media (images\|audio\|all) | images |
131
134
 
132
135
  ## OpenClaw Config Defaults
133
136
 
@@ -309,7 +312,7 @@ When a user requests a "360 video", follow this workflow:
309
312
 
310
313
  ### Transition Video Rule
311
314
 
312
- For **any transition video work**, always use the **Sogni skill/plugin** (not ffmpeg or other methods) unless explicitly told otherwise.
315
+ For **any transition video work**, always use the **Sogni skill/plugin** (not raw ffmpeg or other shell commands). Use the built-in `--extract-last-frame`, `--concat-videos`, and `--looping` flags for video manipulation.
313
316
 
314
317
  ### Insufficient Funds Handling
315
318
 
@@ -385,10 +388,11 @@ sogni-gen -c old_photo.jpg -o restored.png -w 1024 -h 1280 \
385
388
 
386
389
  **Finding received images (Telegram/etc):**
387
390
  ```bash
388
- ls -la ~/.clawdbot/media/inbound/*.jpg | tail -3
389
- cp ~/.clawdbot/media/inbound/<latest>.jpg /tmp/to_restore.jpg
391
+ node {{skillDir}}/sogni-gen.mjs --json --list-media images
390
392
  ```
391
393
 
394
+ **Do NOT use `ls`, `cp`, or other shell commands to browse user files.** Always use `--list-media` to find inbound media.
395
+
392
396
  ## IMPORTANT KEYWORD RULE
393
397
 
394
398
  - If the user message includes the word "photobooth" (case-insensitive), always use `--photobooth` mode with `--ref` set to the user-provided face image.
@@ -417,9 +421,14 @@ node {{skillDir}}/sogni-gen.mjs -q --photobooth --ref /path/to/face.jpg -o /tmp/
417
421
  # Check current SPARK/SOGNI balances (no prompt required)
418
422
  node {{skillDir}}/sogni-gen.mjs --json --balance
419
423
 
424
+ # Find user-sent images/audio
425
+ node {{skillDir}}/sogni-gen.mjs --json --list-media images
426
+
420
427
  # Then send via message tool with filePath
421
428
  ```
422
429
 
430
+ **Security:** Agents must use the CLI's built-in flags (`--extract-last-frame`, `--concat-videos`, `--list-media`) for all file operations and video manipulation. Never run raw shell commands (`ffmpeg`, `ls`, `cp`, etc.) directly.
431
+
423
432
  ## Animate Between Two Images (First-Frame / Last-Frame)
424
433
 
425
434
  When a user asks to **animate between two images**, use `--ref` (first frame) and `--ref-end` (last frame) to create a creative interpolation video:
@@ -433,23 +442,23 @@ node {{skillDir}}/sogni-gen.mjs -q --video --ref /tmp/imageA.png --ref-end /tmp/
433
442
 
434
443
  When a user asks to **animate from a video to an image** (or "continue" a video into a new scene):
435
444
 
436
- 1. **Extract the last frame** of the existing video:
445
+ 1. **Extract the last frame** of the existing video using the built-in safe wrapper:
437
446
  ```bash
438
- ffmpeg -y -sseof -0.1 -i /tmp/existing.mp4 -frames:v 1 -update 1 /tmp/lastframe.png
447
+ node {{skillDir}}/sogni-gen.mjs --extract-last-frame /tmp/existing.mp4 /tmp/lastframe.png
439
448
  ```
440
449
  2. **Generate a new video** using the last frame as `--ref` and the target image as `--ref-end`:
441
450
  ```bash
442
451
  node {{skillDir}}/sogni-gen.mjs -q --video --ref /tmp/lastframe.png --ref-end /tmp/target.png -o /tmp/continuation.mp4 "scene transition prompt"
443
452
  ```
444
- 3. **Stitch the videos** together with ffmpeg:
453
+ 3. **Concatenate the videos** using the built-in safe wrapper:
445
454
  ```bash
446
- ffmpeg -y -i /tmp/existing.mp4 -i /tmp/continuation.mp4 \
447
- -filter_complex "[0:v][1:v]concat=n=2:v=1:a=0[outv]" \
448
- -map "[outv]" -c:v libx264 -crf 18 /tmp/full_sequence.mp4
455
+ node {{skillDir}}/sogni-gen.mjs --concat-videos /tmp/full_sequence.mp4 /tmp/existing.mp4 /tmp/continuation.mp4
449
456
  ```
450
457
 
451
458
  This ensures visual continuity — the new clip picks up exactly where the previous one ended.
452
459
 
460
+ **Do NOT run raw `ffmpeg` commands.** Always use `--extract-last-frame` and `--concat-videos` for video manipulation.
461
+
453
462
  **Always apply this pattern when:**
454
463
  - User says "animate image A to image B" → use `--ref A --ref-end B`
455
464
  - User says "animate this video to this image" → extract last frame, use as `--ref`, target image as `--ref-end`, then stitch
package/llm.txt CHANGED
@@ -135,6 +135,9 @@ node {{skillDir}}/sogni-gen.mjs --json --balance
135
135
  | --last-image | Reuse last generated image as input |
136
136
  | --json | Machine-readable JSON output |
137
137
  | --balance | Show Spark/Sogni token balances |
138
+ | --extract-last-frame VIDEO IMAGE | Extract last frame from a video file |
139
+ | --concat-videos OUTPUT CLIPS... | Concatenate multiple video clips |
140
+ | --list-media [images\|audio\|all] | List recent inbound media files |
138
141
 
139
142
  ## Agent Behavior Guidelines
140
143
 
@@ -147,22 +150,26 @@ node {{skillDir}}/sogni-gen.mjs --json --balance
147
150
  6. Always use -q (quiet) and -o (output to file) so you can send the result back.
148
151
  7. After generating, send the file to the user via the message tool with filePath.
149
152
  8. If you get "Insufficient funds", tell them: "Claim 50 free daily Spark points at https://app.sogni.ai/"
150
- 9. For transition/animation videos, always use this plugin (not ffmpeg) unless told otherwise.
153
+ 9. For transition/animation videos, always use this plugin's built-in flags (not raw ffmpeg). Use `--looping`, `--extract-last-frame`, or `--concat-videos`.
151
154
  10. Default to 768x768 for images. Video sizes must be divisible by 16 (min 480px, max 1536px).
152
155
 
153
156
  ## Finding User-Sent Media
154
157
 
155
- When users send images/audio via Telegram, WhatsApp, or iMessages:
158
+ When users send images/audio via Telegram, WhatsApp, or iMessages, use the built-in `--list-media` flag:
156
159
 
157
160
  ```bash
158
- # Recent inbound images
159
- ls -la ~/.clawdbot/media/inbound/*.jpg | tail -3
160
- ls -la ~/.clawdbot/media/inbound/*.png | tail -3
161
+ # Recent inbound images (default)
162
+ node {{skillDir}}/sogni-gen.mjs --json --list-media images
161
163
 
162
164
  # Recent inbound audio
163
- ls -la ~/.clawdbot/media/inbound/*.m4a | tail -3
165
+ node {{skillDir}}/sogni-gen.mjs --json --list-media audio
166
+
167
+ # All recent media
168
+ node {{skillDir}}/sogni-gen.mjs --json --list-media all
164
169
  ```
165
170
 
171
+ Do NOT use shell commands (`ls`, `cp`, etc.) to browse user media directories.
172
+
166
173
  ## Example Conversations
167
174
 
168
175
  User: "Draw a sunset over mountains"
package/mcp-server.mjs CHANGED
@@ -580,6 +580,58 @@ The face likeness is preserved while applying the style from the prompt.`,
580
580
  properties: {},
581
581
  },
582
582
  },
583
+ {
584
+ name: 'extract_last_frame',
585
+ description: 'Extract the last frame from a video file as an image. Safe ffmpeg wrapper with input sanitization.',
586
+ inputSchema: {
587
+ type: 'object',
588
+ properties: {
589
+ video_path: {
590
+ type: 'string',
591
+ description: 'Path to the source video file',
592
+ },
593
+ output_path: {
594
+ type: 'string',
595
+ description: 'Path to save the extracted frame image (e.g. /tmp/lastframe.png)',
596
+ },
597
+ },
598
+ required: ['video_path', 'output_path'],
599
+ },
600
+ },
601
+ {
602
+ name: 'concat_videos',
603
+ description: 'Concatenate multiple video clips into a single video file. Safe ffmpeg wrapper with input sanitization. Requires at least 2 clips.',
604
+ inputSchema: {
605
+ type: 'object',
606
+ properties: {
607
+ output_path: {
608
+ type: 'string',
609
+ description: 'Path for the concatenated output video',
610
+ },
611
+ clips: {
612
+ type: 'array',
613
+ items: { type: 'string' },
614
+ description: 'Array of video clip file paths to concatenate (minimum 2)',
615
+ minItems: 2,
616
+ },
617
+ },
618
+ required: ['output_path', 'clips'],
619
+ },
620
+ },
621
+ {
622
+ name: 'list_media',
623
+ description: 'List recent inbound media files (images, audio, or all) from the user media directory (~/.clawdbot/media/inbound/). Returns the 5 most recent files sorted by modification time.',
624
+ inputSchema: {
625
+ type: 'object',
626
+ properties: {
627
+ type: {
628
+ type: 'string',
629
+ enum: ['images', 'audio', 'all'],
630
+ description: 'Type of media to list (default: images)',
631
+ },
632
+ },
633
+ },
634
+ },
583
635
  ];
584
636
 
585
637
  // ---------------------------------------------------------------------------
@@ -697,6 +749,40 @@ Defaults:
697
749
  return { content: [{ type: 'text', text }] };
698
750
  }
699
751
 
752
+ async function handleExtractLastFrame(params) {
753
+ const videoPath = sanitizeString(params.video_path, 'video_path');
754
+ const outputPath = sanitizeString(params.output_path, 'output_path');
755
+ const result = await runSogniGen(['--extract-last-frame', videoPath, outputPath], { timeoutMs: 30_000 });
756
+ if (result.success === false) return formatError(result);
757
+ return { content: [{ type: 'text', text: `Extracted last frame to: ${result.outputPath || outputPath}` }] };
758
+ }
759
+
760
+ async function handleConcatVideos(params) {
761
+ const outputPath = sanitizeString(params.output_path, 'output_path');
762
+ if (!params.clips || params.clips.length < 2) {
763
+ return { content: [{ type: 'text', text: 'Error: At least 2 clips are required.' }], isError: true };
764
+ }
765
+ const clips = params.clips.map((c, i) => sanitizeString(c, `clips[${i}]`));
766
+ const result = await runSogniGen(['--concat-videos', outputPath, ...clips], { timeoutMs: 60_000 });
767
+ if (result.success === false) return formatError(result);
768
+ return { content: [{ type: 'text', text: `Concatenated ${result.clipCount || clips.length} clips to: ${result.outputPath || outputPath}` }] };
769
+ }
770
+
771
+ async function handleListMedia(params) {
772
+ const args = ['--list-media'];
773
+ if (params.type) {
774
+ args.push(validateEnum(params.type, ['images', 'audio', 'all'], 'type'));
775
+ }
776
+ const result = await runSogniGen(args, { timeoutMs: 10_000 });
777
+ if (result.success === false) return formatError(result);
778
+ const files = result.files || [];
779
+ if (files.length === 0) {
780
+ return { content: [{ type: 'text', text: `No ${result.mediaType || 'media'} files found.` }] };
781
+ }
782
+ const lines = files.map(f => `${f.name} (${f.size} bytes, ${f.modified})\n ${f.path}`);
783
+ return { content: [{ type: 'text', text: `Recent ${result.mediaType || 'media'} (${files.length}):\n${lines.join('\n')}` }] };
784
+ }
785
+
700
786
  // ---------------------------------------------------------------------------
701
787
  // Server setup
702
788
  // ---------------------------------------------------------------------------
@@ -726,6 +812,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
726
812
  return handleListModels();
727
813
  case 'get_version':
728
814
  return await handleGetVersion();
815
+ case 'extract_last_frame':
816
+ return await handleExtractLastFrame(params);
817
+ case 'concat_videos':
818
+ return await handleConcatVideos(params);
819
+ case 'list_media':
820
+ return await handleListMedia(params);
729
821
  default:
730
822
  return {
731
823
  content: [{ type: 'text', text: `Unknown tool: ${name}` }],
@@ -2,7 +2,7 @@
2
2
  "id": "sogni-gen",
3
3
  "name": "Sogni Image & Video Generation",
4
4
  "description": "Generate images and videos using Sogni AI via the sogni-gen skill.",
5
- "version": "1.5.2",
5
+ "version": "1.5.3",
6
6
  "skills": [
7
7
  "."
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sogni-gen",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "Sogni AI image & video generation — OpenClaw plugin and MCP server for Claude Code / Claude Desktop",
5
5
  "type": "module",
6
6
  "main": "sogni-gen.mjs",
package/sogni-gen.mjs CHANGED
@@ -8,7 +8,7 @@ import { SogniClientWrapper, ClientEvent, getMaxContextImages } from '@sogni-ai/
8
8
  import JSON5 from 'json5';
9
9
  import { createHash, randomBytes } from 'crypto';
10
10
  import { spawnSync } from 'child_process';
11
- import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync, statSync } from 'fs';
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync, statSync, readdirSync, realpathSync, lstatSync } from 'fs';
12
12
  import { join, dirname, basename, extname } from 'path';
13
13
  import { homedir, tmpdir } from 'os';
14
14
  import sharp from 'sharp';
@@ -696,7 +696,12 @@ const options = {
696
696
  sam2Coordinates: null, // SAM2 coordinates for animate-replace [{x,y}]
697
697
  trimEndFrame: false, // Trim last frame for seamless stitching
698
698
  firstFrameStrength: null, // Keyframe interpolation (0.0-1.0)
699
- lastFrameStrength: null // Keyframe interpolation (0.0-1.0)
699
+ lastFrameStrength: null, // Keyframe interpolation (0.0-1.0)
700
+ extractLastFrame: null, // --extract-last-frame <video> <image>
701
+ extractLastFrameOutput: null,
702
+ concatVideos: null, // --concat-videos <out> <clip1> <clip2> [...]
703
+ concatVideosClips: null,
704
+ listMedia: null // --list-media [images|audio|all]
700
705
  };
701
706
  const cliSet = {
702
707
  output: false,
@@ -993,6 +998,39 @@ for (let i = 0; i < args.length; i++) {
993
998
  i++;
994
999
  options.lastFrameStrength = parseNumberValue(raw, arg);
995
1000
  cliSet.lastFrameStrength = true;
1001
+ } else if (arg === '--extract-last-frame') {
1002
+ const videoArg = requireFlagValue(args, i, arg);
1003
+ i++;
1004
+ const imageArg = requireFlagValue(args, i, arg + ' (output image)');
1005
+ i++;
1006
+ options.extractLastFrame = videoArg;
1007
+ options.extractLastFrameOutput = imageArg;
1008
+ } else if (arg === '--concat-videos') {
1009
+ // Consume remaining positional args: <output> <clip1> <clip2> [clip3...]
1010
+ const outArg = requireFlagValue(args, i, arg + ' (output path)');
1011
+ i++;
1012
+ const clips = [];
1013
+ while (i + 1 < args.length && !args[i + 1].startsWith('-')) {
1014
+ i++;
1015
+ clips.push(args[i]);
1016
+ }
1017
+ if (clips.length < 2) {
1018
+ fatalCliError('--concat-videos requires at least 2 clip paths after the output path.', {
1019
+ code: 'INVALID_ARGUMENT',
1020
+ details: { flag: '--concat-videos', clipsProvided: clips.length }
1021
+ });
1022
+ }
1023
+ options.concatVideos = outArg;
1024
+ options.concatVideosClips = clips;
1025
+ } else if (arg === '--list-media') {
1026
+ // Optional type argument (images|audio|all), default: images
1027
+ const next = args[i + 1];
1028
+ if (next && !next.startsWith('-') && ['images', 'audio', 'all'].includes(next)) {
1029
+ i++;
1030
+ options.listMedia = next;
1031
+ } else {
1032
+ options.listMedia = 'images';
1033
+ }
996
1034
  } else if (arg === '--last-image') {
997
1035
  // Use image from last render as reference/context
998
1036
  if (existsSync(LAST_RENDER_PATH)) {
@@ -1098,6 +1136,9 @@ General:
1098
1136
  --token-type <type> Token type: spark|sogni (default: spark)
1099
1137
  --balance, --balances Show SPARK/SOGNI balances and exit
1100
1138
  --version, -V Show sogni-gen version and exit
1139
+ --extract-last-frame <video> <image> Extract last frame from a video (safe ffmpeg wrapper)
1140
+ --concat-videos <out> <clips...> Concatenate video clips (safe ffmpeg wrapper, min 2 clips)
1141
+ --list-media [type] List recent inbound media files (images|audio|all, default: images)
1101
1142
  --last Show last render info (JSON)
1102
1143
  --json Output JSON with all details
1103
1144
  --strict-size Do not auto-adjust video size to satisfy i2v reference resizing constraints
@@ -1440,7 +1481,7 @@ if (options.video) {
1440
1481
  options.model = options.model || openclawConfig?.defaultImageModel || 'z_image_turbo_bf16';
1441
1482
  }
1442
1483
 
1443
- if (!options.prompt && !options.estimateVideoCost && !options.multiAngle && !options.showBalance && !options.showVersion) {
1484
+ if (!options.prompt && !options.estimateVideoCost && !options.multiAngle && !options.showBalance && !options.showVersion && !options.extractLastFrame && !options.concatVideos && !options.listMedia) {
1444
1485
  fatalCliError('No prompt provided. Use --help for usage.', { code: 'INVALID_ARGUMENT' });
1445
1486
  }
1446
1487
 
@@ -1717,7 +1758,7 @@ if (options.lastSeed) {
1717
1758
  }
1718
1759
  }
1719
1760
 
1720
- if (!options.estimateVideoCost && !options.showVersion && (options.seed === null || options.seed === undefined)) {
1761
+ if (!options.estimateVideoCost && !options.showVersion && !options.extractLastFrame && !options.concatVideos && !options.listMedia && (options.seed === null || options.seed === undefined)) {
1721
1762
  const strategy = options.seedStrategy || openclawConfig?.seedStrategy || 'prompt-hash';
1722
1763
  const normalized = normalizeSeedStrategy(strategy) || 'prompt-hash';
1723
1764
  options.seedStrategy = normalized;
@@ -2459,6 +2500,120 @@ async function main() {
2459
2500
  return;
2460
2501
  }
2461
2502
 
2503
+ // --- Utility commands (no Sogni auth required) ---
2504
+
2505
+ if (options.extractLastFrame) {
2506
+ const videoPath = sanitizePath(options.extractLastFrame, '--extract-last-frame video');
2507
+ const outputPath = sanitizePath(options.extractLastFrameOutput, '--extract-last-frame output');
2508
+ if (!existsSync(videoPath)) {
2509
+ const err = new Error(`Video file not found: ${videoPath}`);
2510
+ err.code = 'FILE_NOT_FOUND';
2511
+ throw err;
2512
+ }
2513
+ extractLastFrameFromVideo(videoPath, outputPath);
2514
+ if (options.json || JSON_ERROR_MODE) {
2515
+ console.log(JSON.stringify({
2516
+ success: true,
2517
+ type: 'extract-last-frame',
2518
+ outputPath,
2519
+ timestamp: new Date().toISOString()
2520
+ }));
2521
+ } else {
2522
+ console.log(`Extracted last frame to: ${outputPath}`);
2523
+ }
2524
+ return;
2525
+ }
2526
+
2527
+ if (options.concatVideos) {
2528
+ const outputPath = sanitizePath(options.concatVideos, '--concat-videos output');
2529
+ const clips = options.concatVideosClips.map((c, i) => sanitizePath(c, `clip[${i}]`));
2530
+ for (const clip of clips) {
2531
+ if (!existsSync(clip)) {
2532
+ const err = new Error(`Clip file not found: ${clip}`);
2533
+ err.code = 'FILE_NOT_FOUND';
2534
+ throw err;
2535
+ }
2536
+ }
2537
+ buildConcatVideoFromClips(outputPath, clips);
2538
+ if (options.json || JSON_ERROR_MODE) {
2539
+ console.log(JSON.stringify({
2540
+ success: true,
2541
+ type: 'concat-videos',
2542
+ outputPath,
2543
+ clipCount: clips.length,
2544
+ timestamp: new Date().toISOString()
2545
+ }));
2546
+ } else {
2547
+ console.log(`Concatenated ${clips.length} clips to: ${outputPath}`);
2548
+ }
2549
+ return;
2550
+ }
2551
+
2552
+ if (options.listMedia) {
2553
+ const mediaType = options.listMedia;
2554
+ const baseDir = join(homedir(), '.clawdbot', 'media', 'inbound');
2555
+
2556
+ const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
2557
+ const AUDIO_EXTS = new Set(['.m4a', '.mp3', '.wav', '.ogg']);
2558
+
2559
+ let allowedExts;
2560
+ if (mediaType === 'images') allowedExts = IMAGE_EXTS;
2561
+ else if (mediaType === 'audio') allowedExts = AUDIO_EXTS;
2562
+ else allowedExts = new Set([...IMAGE_EXTS, ...AUDIO_EXTS]);
2563
+
2564
+ const files = [];
2565
+ if (existsSync(baseDir)) {
2566
+ // Validate the base directory itself isn't a symlink pointing outside ~/.clawdbot/
2567
+ const clawdbotRoot = join(homedir(), '.clawdbot');
2568
+ const resolvedBase = realpathSync(baseDir);
2569
+ if (!resolvedBase.startsWith(clawdbotRoot)) {
2570
+ const err = new Error('Media directory resolves outside of ~/.clawdbot/');
2571
+ err.code = 'INVALID_PATH';
2572
+ throw err;
2573
+ }
2574
+
2575
+ const entries = readdirSync(baseDir);
2576
+ for (const entry of entries) {
2577
+ const ext = extname(entry).toLowerCase();
2578
+ if (!allowedExts.has(ext)) continue;
2579
+ const fullPath = join(baseDir, entry);
2580
+ // Skip symlinks
2581
+ const lstats = lstatSync(fullPath);
2582
+ if (lstats.isSymbolicLink()) continue;
2583
+ if (!lstats.isFile()) continue;
2584
+ files.push({
2585
+ path: fullPath,
2586
+ name: entry,
2587
+ size: lstats.size,
2588
+ modified: lstats.mtime.toISOString()
2589
+ });
2590
+ }
2591
+ // Sort by mtime descending, return top 5
2592
+ files.sort((a, b) => b.modified.localeCompare(a.modified));
2593
+ files.splice(5);
2594
+ }
2595
+
2596
+ if (options.json || JSON_ERROR_MODE) {
2597
+ console.log(JSON.stringify({
2598
+ success: true,
2599
+ type: 'list-media',
2600
+ mediaType,
2601
+ files,
2602
+ timestamp: new Date().toISOString()
2603
+ }));
2604
+ } else {
2605
+ if (files.length === 0) {
2606
+ console.log(`No ${mediaType} files found in ${baseDir}`);
2607
+ } else {
2608
+ console.log(`Recent ${mediaType} (${files.length}):`);
2609
+ for (const f of files) {
2610
+ console.log(` ${f.name} (${f.size} bytes, ${f.modified})`);
2611
+ }
2612
+ }
2613
+ }
2614
+ return;
2615
+ }
2616
+
2462
2617
  const creds = loadCredentials();
2463
2618
  log('Connecting to Sogni...');
2464
2619
  client = new SogniClientWrapper({