@sogni-ai/sogni-creative-agent-skill 3.1.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,7 @@ With this skill, an agent can:
42
42
  - [Requirements](#requirements)
43
43
  - [Installation](#installation)
44
44
  - [Node CLI (default)](#node-cli-default)
45
+ - [Claude Code plugin](#claude-code-plugin)
45
46
  - [OpenClaw plugin](#openclaw-plugin)
46
47
  - [Hermes Agent / Manus / other frameworks](#hermes-agent--manus--other-frameworks)
47
48
  - [Manual install from source](#manual-install-from-source)
@@ -111,6 +112,17 @@ sogni-agent --version
111
112
 
112
113
  Then point your agent/runtime at this repository's [`SKILL.md`](./SKILL.md). When an install request is ambiguous, install the CLI and skill source together — that's the supported default.
113
114
 
115
+ ### Claude Code plugin
116
+
117
+ The Claude Code plugin shells out to the `sogni-agent` CLI installed above, so both steps are required. From inside Claude Code, register the marketplace and install the plugin:
118
+
119
+ ```text
120
+ /plugin marketplace add Sogni-AI/sogni-creative-agent-skill
121
+ /plugin install sogni-creative-agent@sogni
122
+ ```
123
+
124
+ The first command registers a `sogni` marketplace with one plugin entry (`sogni-creative-agent`) backed by a lean Claude-Code-focused [`plugin-skills/sogni-creative-agent/SKILL.md`](./plugin-skills/sogni-creative-agent/SKILL.md); the second installs the plugin into Claude Code. The full skill spec still lives at the repository root [`SKILL.md`](./SKILL.md).
125
+
114
126
  ### OpenClaw plugin
115
127
 
116
128
  For the published plugin:
@@ -275,6 +287,10 @@ sogni-agent --api-chat --task-profile reasoning --no-thinking \
275
287
  "Plan a concise multi-step product launch workflow"
276
288
  sogni-agent --list-api-models
277
289
 
290
+ # Durable hosted chat run with SSE progress events
291
+ SOGNI_SKILL_USE_SDK_TRANSPORT=1 sogni-agent --durable-chat \
292
+ "Create a product launch storyboard and render the first hero image"
293
+
278
294
  # Durable hosted workflow (/v1/creative-agent/workflows)
279
295
  sogni-agent --api-workflow \
280
296
  --video-prompt "The camera slowly pushes in as the sketch comes alive" \
@@ -299,7 +315,7 @@ sogni-agent --get-replay run_abc123 --json
299
315
 
300
316
  # Opt in to SDK transport for hosted operations (durable workflows + chat).
301
317
  # Validates restEndpoint/socketEndpoint via the skill's SSRF guard, then
302
- # calls sogni.workflows.* / .chat.completions.* directly.
318
+ # calls the SDK workflow/chat methods directly.
303
319
  # Falls back to the legacy SSRF-validated fetch path when the env is unset.
304
320
  export SOGNI_SKILL_USE_SDK_TRANSPORT=1
305
321
  sogni-agent --api-workflow storyboard-video "10s neon city flyover"
@@ -508,6 +524,7 @@ Hosted API modes require `SOGNI_API_KEY`.
508
524
 
509
525
  - **`--api-chat`** targets `/v1/chat/completions` with Sogni creative-agent tools — best for text-first natural-language workflows. The CLI sanitizes prompt-injection markers before forwarding messages and can use the current server-side creative-agent media tools, including video extension, segment replacement, overlays, subtitles, stitch/orbit/dance composition, and generated artifact indexing. Tune with `--api-tools creative-agent|creative-tools|none`, `--no-api-tool-execution`, `--llm-model`, and `--system`.
510
526
  - **Sogni Intelligence controls** include `--task-profile general|coding|reasoning`, `--max-tokens`, and `--thinking` / `--no-thinking`, which forward to `/v1/chat/completions` as `task_profile`, `max_tokens`, and `chat_template_kwargs.enable_thinking`. Use `--list-api-models` or `--get-api-model <id>` to inspect `/v1/models`.
527
+ - **`--durable-chat`** starts a hosted `/v1/chat/runs` record through the SDK transport. Set `SOGNI_SKILL_USE_SDK_TRANSPORT=1` before using it. The CLI streams assistant deltas and de-duplicated per-job progress / ETA / result lines from hosted run events.
511
528
  - **`--api-workflow`** targets `/v1/creative-agent/workflows` for durable, async workflow records with event streaming and cancellation. Requests carry `input.steps` plus snake_case controls such as `token_type`, `media_references`, `max_estimated_capacity_units`, and `confirm_cost`.
512
529
  - **`--workflow-input`** forwards exact durable workflow JSON (`{ title?, steps: [...] }`). Use this when you need exact multi-step behavior such as repeated `replace_video_segment` steps with `replacementStartSeconds` / `replacementEndSeconds` for interleaved video slices.
513
530
  - **`--api-workflow storyboard-video`** generates a storyline, creates a single GPT Image 2 storyboard sheet, then passes that artifact into Seedance as the video reference. The `-Q fast|hq|pro` preset maps to GPT Image 2 low/medium/high quality for that storyboard sheet.
@@ -601,7 +618,7 @@ With both repos checked out as siblings, refresh the generated runtime before pu
601
618
  npm run sync:creative-agent-runtime
602
619
  ```
603
620
 
604
- Reusable workflow rules should be added to `../sogni-creative-agent` first, then synced here. Keep storyboard planning, tool argument validation, prompt linting, typed media turn intent, and typed repair/control semantics aligned with `sogni-chat`, `sogni-client`, and `sogni-api` hosted chat/workflow endpoints rather than recreating skill-only regex guards. Prefer generated or copied shared helpers for hosted workflow compilation, schema argument validation, `CreativeTurnPlannerFields` / `classifyMediaTurnIntent()` media-routing contracts, repair-control decisions, and guard telemetry summaries over skill-local guard code — this keeps public-agent behavior close to `/v1/chat/completions` and `/v1/creative-agent/workflows`.
621
+ Reusable workflow rules should come from the shared Sogni runtime before they are synced into this public package. Keep storyboard planning, tool argument validation, prompt linting, media-routing decisions, chat-run progress extraction, and repair/control behavior aligned with the hosted `/v1/chat/completions` and `/v1/creative-agent/workflows` APIs. Prefer typed helpers exported by `@sogni-ai/sogni-intelligence-client` or the generated runtime over new skill-local regex guards.
605
622
 
606
623
  Public-skill regex should stay limited to CLI argument/fact extraction such as file paths, URLs, extensions, dimensions, durations, and explicit positions. Hosted-style decisions such as latest-video continuation, uploaded-video modification, image-selection waits, stitch-after-batch state, and repair/control routing belong upstream in typed planner/runtime fields before they are synced here.
607
624
 
package/SKILL.md CHANGED
@@ -128,12 +128,16 @@ Path override environment variables:
128
128
 
129
129
  ## Recommended path: route through the hosted Sogni Intelligence endpoints
130
130
 
131
- For any natural-language creative request — anything that should be planned, multi-step, or that benefits from tool selection, repair, or durable workflows — prefer the hosted endpoints over the direct-to-SDK flags. The hosted endpoints are the canonical home for tool dispatch, Structured Contracts v1 (gating policies, repair recipes, prompt contracts), durable workflows, replay, and asset-manifest mapping. They stay aligned with `sogni-chat` and the rest of the `@sogni/creative-agent` consumers automatically.
131
+ For any natural-language creative request — anything that should be planned, multi-step, resumable, or that benefits from tool selection, repair, or durable workflows — prefer the hosted Sogni Intelligence endpoints over the direct-to-SDK media flags. The hosted surfaces are the canonical home for OpenAI-compatible chat, server-side creative tool dispatch, Structured Contracts v1 (gating policies, repair recipes, prompt contracts), durable chat runs, durable workflows, workflow templates, replay, and asset-manifest mapping. They stay aligned with `sogni-chat`, `sogni-api`, and the rest of the `@sogni/creative-agent` consumers.
132
132
 
133
133
  ```bash
134
134
  # Natural-language creative request (LLM picks the tool, dispatches, repairs)
135
135
  sogni-agent --api-chat "Turn the attached product photo into a launch poster" --ref product.jpg
136
136
 
137
+ # Durable hosted chat run (persisted event log + SSE stream)
138
+ SOGNI_SKILL_USE_SDK_TRANSPORT=1 sogni-agent --durable-chat \
139
+ "Create a four-shot launch campaign, generate the key art, and animate the hero clip"
140
+
137
141
  # Multi-step durable workflow (resumable, replay-friendly, server-orchestrated)
138
142
  sogni-agent --api-workflow \
139
143
  --video-prompt "The camera slowly pushes in" \
@@ -196,11 +200,15 @@ sogni-agent --api-chat --ref product.jpg \
196
200
 
197
201
  # Sogni Intelligence model/replay utilities
198
202
  sogni-agent --list-api-models
199
- sogni-agent --api-chat --task-profile reasoning --no-thinking \
203
+ sogni-agent --api-chat --task-profile reasoning --max-tokens 2000 \
200
204
  "Plan a concise multi-step product launch workflow"
201
205
  sogni-agent --list-replays 20
202
206
  sogni-agent --get-replay run_abc123 --json
203
207
 
208
+ # Draft a savable workflow template through the hosted creative-agent tool loop
209
+ sogni-agent --api-chat \
210
+ "Design a reusable workflow for a 9:16 product teaser from one product photo"
211
+
204
212
  # Durable API workflow: generated keyframe to video with resumable workflow record
205
213
  sogni-agent --api-workflow \
206
214
  --video-prompt "The camera slowly pushes in as the sketch comes alive" \
@@ -214,43 +222,128 @@ sogni-agent --api-workflow \
214
222
  "Animate the referenced sketch"
215
223
 
216
224
  # Exact durable workflow input with explicit steps
217
- sogni-agent --api-workflow --workflow-input @workflow.json
225
+ sogni-agent --api-workflow --workflow-input @workflow-input.json \
226
+ --workflow-idempotency-key product-teaser-v1
218
227
 
219
228
  # Durable storyboard-video workflow: storyline -> GPT Image 2 storyboard -> Seedance
220
229
  sogni-agent --api-workflow storyboard-video --storyboard-frames 6 --duration 12 -Q hq \
221
230
  "Create a 9:16 bakery launch video with a neon street-window reveal"
231
+
232
+ # Workflow management
233
+ sogni-agent --list-workflows
234
+ sogni-agent --resume-workflow wf_durable_workflow_123
222
235
  ```
223
236
 
224
237
  Use `--api-chat` for text-first natural-language workflows that should go through
225
- Sogni API's OpenAI-compatible `/v1/chat/completions` tool loop. This path
226
- sanitizes prompt-injection markers before forwarding messages and uses the
227
- current hosted creative-agent tool surface. Use `--api-workflow` when the caller
228
- already knows it wants an async durable workflow record under
229
- `/v1/creative-agent/workflows`. Use `--workflow-input @workflow.json` when the
230
- caller already has exact durable workflow input with `steps`; the skill forwards
231
- that body to the API as-is. This is the preferred hosted path for
232
- exact multi-step plans, including repeated `replace_video_segment` operations
233
- with `replacementStartSeconds` / `replacementEndSeconds` when interleaving
234
- existing video slices. Use `--api-workflow storyboard-video`
235
- when the caller wants the hosted sequence to generate a storyline, create one GPT
236
- Image 2 storyboard sheet, and feed that image artifact into Seedance as the video
237
- reference. The `-Q fast|hq|pro` preset maps to GPT Image 2 low|medium|high
238
- quality for the storyboard sheet. Hosted API requests forward media references
239
- from `-c`, `--ref`, `--ref-end`, `--ref-audio`,
240
- `--reference-audio-identity`, and `--ref-video` as `media_references`
241
- metadata; workflow JSON can bind them into step arguments with
242
- `sourceStepId: "$input_media"`, and API chat also attaches image refs as vision
243
- inputs. Local file references are uploaded to Sogni media storage first, then
244
- forwarded as retrievable URLs for hosted chat and durable workflows. Use the
245
- direct CLI path for private media that must not leave the local machine.
246
- Use `--workflow-max-cost <n>` plus `--confirm-cost` / `--no-confirm-cost` to
247
- forward explicit workflow cost policy.
248
- Sogni Intelligence utilities are exposed through the same API key path:
249
- `--list-api-models` / `--get-api-model <id>` read `/v1/models`,
250
- `--task-profile`, `--max-tokens`, and `--thinking` / `--no-thinking` tune
251
- `/v1/chat/completions`, and `--list-replays`, `--get-replay`, and
252
- `--ingest-replay` manage `/v1/replay/records` RunRecords for replay/debug
253
- viewers.
238
+ Sogni API's OpenAI-compatible `POST /v1/chat/completions` loop. The public
239
+ REST body uses snake_case controls such as `tool_choice`, `response_format`,
240
+ `task_profile`, `token_type`, `app_source`, `media_references`,
241
+ `chat_template_kwargs`, `sogni_tools`, and `sogni_tool_execution`. The endpoint
242
+ normalizes OpenAI `developer` messages to `system`; when a developer message is
243
+ present and no explicit `task_profile` is supplied, the server treats the task
244
+ as `coding`. The CLI sanitizes prompt-injection markers before forwarding
245
+ messages and sends API-key auth so hosted Sogni tools can execute server-side.
246
+
247
+ Hosted tool surfaces are split by `sogni_tools`:
248
+
249
+ - `creative-tools` is the public API default when `sogni_tools` is omitted or
250
+ true. It exposes generation/editing tools (`generate_image`,
251
+ `generate_video`, `generate_music`, `edit_image`, `apply_style`,
252
+ `restore_photo`, `refine_result`, `animate_photo`, `change_angle`,
253
+ `video_to_video`, `stitch_video`, `orbit_video`, `dance_montage`,
254
+ `sound_to_video`, `extend_video`, `replace_video_segment`, `overlay_video`,
255
+ `add_subtitles`), media-analysis tools (`analyze_image`, `analyze_video`,
256
+ `extract_metadata`), and lightweight composition tools (`enhance_prompt`,
257
+ `compose_lyrics`, `compose_instrumental`, `compose_script`).
258
+ - `creative-agent` is this CLI's default for `--api-chat`. It includes the
259
+ `creative-tools` surface plus session-control tools
260
+ (`ask_clarifying_question`, `finalize_response`), asset-manifest tools
261
+ (`create_asset_manifest`, `inspect_asset`, `label_asset`,
262
+ `map_assets_for_model`, `validate_asset_references`), and durable planning
263
+ tools (`compose_workflow`, `compose_workflow_template`). Use this surface
264
+ when the model should design one-shot workflow plans, draft savable workflow
265
+ templates, or maintain stable asset references across a multi-step turn.
266
+ - `none` disables Sogni tool injection and leaves only caller-supplied OpenAI
267
+ tools on raw API/SDK requests. In the CLI, use it with
268
+ `--no-api-tool-execution` when you want text-only planning without hosted
269
+ Sogni tool dispatch.
270
+
271
+ Use `--durable-chat` for long-running, LLM-in-the-loop turns that should be
272
+ persisted as `POST /v1/chat/runs` records instead of a single
273
+ `/v1/chat/completions` request. Chat runs keep an event log, stream via
274
+ `/v1/chat/runs/:id/events/stream`, support cancellation, and can pause for
275
+ persisted cost approval (`/v1/chat/runs/:id/confirm-cost`) in first-party
276
+ clients. The CLI can start and stream durable chat runs through the SDK
277
+ transport when `SOGNI_SKILL_USE_SDK_TRANSPORT=1` is set.
278
+
279
+ Use `--api-workflow` when the caller already knows it wants an async durable
280
+ workflow under `POST /v1/creative-agent/workflows`. The API now accepts either
281
+ an inline durable plan (`input.steps`) or a saved workflow template invocation
282
+ (`workflow_id` plus `inputs`) and rejects requests that provide both. The CLI's
283
+ generated-keyframe and `storyboard-video` presets submit inline `input.steps`;
284
+ `--workflow-input @workflow-input.json` supplies that `input` object directly.
285
+ Saved template CRUD lives at `/v1/creative-agent/workflows/templates`, and a
286
+ saved template can later be run by API/SDK callers with `workflow_id + inputs`.
287
+ Use `compose_workflow_template` through `--api-chat` to draft a savable template;
288
+ the caller is still responsible for persisting the returned `template_draft`.
289
+
290
+ Exact multi-step workflow plans should use explicit step dependencies, including
291
+ `replace_video_segment` steps with bounded `replacementStartSeconds` /
292
+ `replacementEndSeconds` when interleaving existing video slices. Workflow JSON
293
+ can bind request media into step arguments with `sourceStepId: "$input_media"`.
294
+ Use `--api-workflow storyboard-video` when the hosted sequence should generate a
295
+ storyline, create one GPT Image 2 storyboard sheet, and feed that image artifact
296
+ into Seedance as the video reference. The `-Q fast|hq|pro` preset maps to GPT
297
+ Image 2 low|medium|high quality for the storyboard sheet.
298
+
299
+ Hosted API requests forward media references from `-c`, `--ref`, `--ref-end`,
300
+ `--ref-audio`, `--reference-audio-identity`, and `--ref-video` as
301
+ `media_references` metadata. `--ref-audio` and `--ref-video` are repeatable in
302
+ api-chat / durable-chat mode — each entry uploads independently and is exposed
303
+ to the hosted LLM at `@Audio1` / `@Audio2` / `@Video1` etc. API chat also
304
+ attaches image refs as vision inputs. Local file references are uploaded to
305
+ Sogni media storage first, then forwarded as retrievable URLs for hosted chat
306
+ and durable workflows. Use the direct CLI path for private media that must not
307
+ leave the local machine.
308
+
309
+ ### Seedance reference modes (mutually exclusive)
310
+
311
+ When `--video -m seedance2` or `-m seedance2-fast` is selected, the skill
312
+ exposes the same two-mode pattern that the hosted chat surfaces. Pick one
313
+ mode per video request:
314
+
315
+ - **Dedicated frame mode — `--ref` and/or `--ref-end`.** First-class
316
+ first-frame / last-frame anchoring; the Seedance worker pins them as
317
+ parameter-mode firstFrame / lastFrame. Max 2 images.
318
+ - **Loose reference mode — `-c/--context` plus optional `--ref-audio`
319
+ extras and `--ref-video` extras.** Anchor frame intent in the prompt with
320
+ `@Image1` / `@Image2` / `@Video1` / `@Audio1` etc. (e.g. *"Use @Image1 as
321
+ the opening shot reference"*). Supports up to 9 image refs, 3 video refs,
322
+ 3 audio refs, and 12 total reference assets per video request. The
323
+ numeric caps come from the canonical
324
+ `@sogni-ai/sogni-protocol/catalogs/seedance-reference-limits.json` catalog,
325
+ surfaced through `@sogni-ai/sogni-intelligence-client/tools` as
326
+ `SEEDANCE_REFERENCE_LIMITS` and `validateSeedanceReferenceCounts()`.
327
+
328
+ Combining `--ref` / `--ref-end` with `-c/--context` on Seedance is rejected
329
+ client-side with a clear error pointing to the correct mode. In CLI direct-gen
330
+ mode, additional `--ref-audio` / `--ref-video` entries beyond the first must
331
+ be HTTPS URLs (the primary entry can still be a local file path); for local
332
+ multi-file Seedance uploads, use `--api-chat` / `--durable-chat` instead. Use
333
+ `--workflow-max-cost <n>` plus `--confirm-cost` / `--no-confirm-cost` to forward
334
+ explicit workflow cost policy, and `--workflow-idempotency-key` when retrying a
335
+ workflow start request.
336
+
337
+ Sogni Intelligence utilities are exposed through the same API-key path:
338
+ `--list-api-models` / `--get-api-model <id>` read `/v1/models`, `--task-profile`
339
+ and `--max-tokens` tune `/v1/chat/completions`, and `--list-replays`,
340
+ `--get-replay`, and `--ingest-replay` manage `/v1/replay/records` RunRecords for
341
+ replay/debug viewers. The public chat endpoint also accepts OpenAI-standard
342
+ `reasoning_effort` / `reasoning.effort` in raw API requests. The CLI's
343
+ `--thinking` / `--no-thinking` flags are forwarded as
344
+ `chat_template_kwargs.enable_thinking`; current hosted Qwen requests may
345
+ normalize thinking on server-side, so do not rely on `--no-thinking` as a hard
346
+ suppression switch for `/v1/chat/completions`.
254
347
  Hosted API modes require `SOGNI_API_KEY`; this skill's CLI uses API-key
255
348
  authentication.
256
349
 
@@ -263,15 +356,15 @@ SSRF-validated fetch path. The skill's `sogni-hosted-client.mjs`
263
356
  factory still validates `restEndpoint` / `socketEndpoint` against the
264
357
  SSRF guard before constructing the SDK client, so the safety contract
265
358
  holds.
359
+ For `--durable-chat`, stream output as the run advances; the CLI reports
360
+ assistant deltas plus de-duplicated per-job progress / ETA / result lines from
361
+ hosted run events.
266
362
 
267
363
  When changing hosted API chat/workflow behavior, keep reusable validation,
268
- workflow compilation, repair-control, and guard telemetry logic in
269
- `../sogni-creative-agent` first. The public skill should consume generated or
270
- copied shared contracts instead of adding skill-local regex guards. Media-routing
271
- decisions should come from typed planner/runtime contracts such as
272
- `CreativeTurnPlannerFields`, `classifyMediaTurnIntent()`, `videoContinuation`,
273
- `videoModification`, `outputGrouping`, `imageSelectionPolicy`, and
274
- `pendingStitchAfterBatch`; regex is appropriate only for bounded CLI/fact
364
+ workflow compilation, repair-control, and guard telemetry logic in the shared
365
+ Sogni runtime first, then sync it into this public skill. The public skill
366
+ should consume generated or shared typed contracts instead of adding
367
+ skill-local regex guards. Keep local regex limited to bounded CLI/fact
275
368
  extraction such as paths, URLs, extensions, dimensions, durations, and explicit
276
369
  positions.
277
370
 
@@ -358,13 +451,15 @@ positions.
358
451
  | `--concat-audio <path>` | Optional audio track to mux over `--concat-videos` output | - |
359
452
  | `--concat-audio-start <sec>` | Start offset into `--concat-audio` | - |
360
453
  | `--list-media [type]` | List recent inbound media (images\|audio\|all) | images |
361
- | `--api-chat` | Call `/v1/chat/completions` with Sogni creative-agent tool injection | - |
362
- | `--api-tools <mode>` | API tool mode: creative-agent\|creative-tools\|none | creative-agent |
454
+ | `--api-chat` | Call OpenAI-compatible `/v1/chat/completions`; CLI default sends the hosted `creative-agent` tool surface | - |
455
+ | `--durable-chat` | Start and stream a durable `/v1/chat/runs` record through SDK transport; requires `SOGNI_SKILL_USE_SDK_TRANSPORT=1` | - |
456
+ | `--api-tools <mode>` | API tool mode: creative-agent\|creative-tools\|none. CLI default is creative-agent; raw API default is creative-tools. | creative-agent |
363
457
  | `--no-api-tool-execution` | Plan/tool-call via API chat without executing Sogni tools | - |
364
458
  | `--llm-model <id>` | LLM model for `--api-chat` | qwen3.6-35b-a3b-gguf-iq4xs |
365
459
  | `--task-profile <profile>` | Sogni Intelligence task profile: general\|coding\|reasoning | - |
366
460
  | `--max-tokens <n>` | Max hosted chat completion tokens | 1600 |
367
- | `--thinking`, `--no-thinking` | Toggle `chat_template_kwargs.enable_thinking` for hosted chat | server default |
461
+ | `--thinking`, `--no-thinking` | Forward `chat_template_kwargs.enable_thinking` for hosted chat; current public Qwen requests may normalize thinking on server-side | server default |
462
+ | `--system <text>` | Override the base system prompt for hosted chat | built-in creative assistant prompt |
368
463
  | `--list-api-models`, `--get-api-model <id>` | Inspect Sogni Intelligence LLM model metadata | - |
369
464
  | `--list-replays [n]`, `--get-replay <id>`, `--ingest-replay <json\|@path>` | Manage Sogni Intelligence replay RunRecords. List/get output is run through `redactRunRecord` from `@sogni/creative-agent/replay` before printing, so signed URLs, bearer tokens, JWTs, and PEM blocks cannot leak via the CLI. Use `@path` to load JSON from a file. | - |
370
465
  | `--skip-redact`, `--no-redact` | Bypass the replay redactor on `--list-replays` / `--get-replay`. Debug-only — emits unredacted RunRecord payloads. | redacted |
@@ -376,9 +471,10 @@ positions.
376
471
  | `--storyboard-plan-frames <n>` | Frame count for `--storyboard-plan`. | inferred |
377
472
  | `--storyboard-plan-model <id>` | Adapter target for `--storyboard-plan` (seedance, seedance2, gpt-image-2, ltx23, wan). | inferred |
378
473
  | `--storyboard-plan-stage <stage>` | Compilation stage for `--storyboard-plan` (storyboard_image, scene_clip). | storyboard_image |
379
- | `--api-workflow` | Start a durable workflow with explicit `input.steps`; optional `storyboard-video` preset | - |
380
- | `--workflow-input <json\|@path>` | Durable workflow input JSON. Use `@path` to load from a file. | - |
474
+ | `--api-workflow` | Start `/v1/creative-agent/workflows` with generated inline `input.steps`; optional `storyboard-video` preset | - |
475
+ | `--workflow-input <json\|@path>` | Durable workflow `input` JSON for the start request. Use `@path` to load from a file. | - |
381
476
  | `--workflow-title <text>` | Title for generated or storyboard durable workflow input | - |
477
+ | `--workflow-idempotency-key <key>`, `--idempotency-key <key>` | Reuse safely when retrying a durable workflow start request | - |
382
478
  | `--workflow-max-cost <n>` | Reject hosted workflow starts above this estimated capacity-unit ceiling | - |
383
479
  | `--confirm-cost`, `--no-confirm-cost` | Forward explicit hosted workflow cost confirmation | - |
384
480
  | `--storyboard-frames <n>` | Beat count for storyboard-video workflow | - |
@@ -387,7 +483,7 @@ positions.
387
483
  | `--generate-audio`, `--no-generate-audio` | Toggle audio generation for generated video steps | - |
388
484
  | `--expand-prompt`, `--no-expand-prompt` | Toggle prompt expansion for generated video steps | - |
389
485
  | `--watch-workflow` | Stream durable workflow events after start | - |
390
- | `--list-workflows`, `--get-workflow <id>`, `--workflow-events <id>`, `--stream-workflow <id>`, `--cancel-workflow <id>` | Durable workflow management helpers | - |
486
+ | `--list-workflows`, `--get-workflow <id>`, `--workflow-events <id>`, `--stream-workflow <id>`, `--cancel-workflow <id>`, `--resume-workflow <id>` | Durable workflow management helpers | - |
391
487
  | `--api-base-url <url>` | Sogni API base for hosted API modes. Credentials are only sent to `https://api.sogni.ai` by default; use `SOGNI_API_ALLOWED_HOSTS` for trusted custom hosts or `SOGNI_ALLOW_UNSAFE_API_BASE_URL=1` for isolated local testing. | https://api.sogni.ai |
392
488
  | `--no-filter` | Disable NSFW content filter | - |
393
489
  | `--memory-set <key> <value>` | Save a user preference | - |
@@ -2285,7 +2285,7 @@ const PROMPT_CONTRACTS = [
2285
2285
  "contractId": "sound_to_video_v1",
2286
2286
  "version": "1.0.0",
2287
2287
  "toolName": "sound_to_video",
2288
- "baseDescription": "sound_to_video creates audio-synced video from an audio source. Works with uploaded audio\nfiles (mp3, m4a, wav) OR previously generated music from generate_music (auto-detected).\n\nWhen the user asks to \"turn that song/music into a video\" after generate_music, use\nsound_to_video — it will automatically find the generated audio.\n\nFor music visualization (syncing video to a specific song or audio track), use the\ngenerate_music → sound_to_video pipeline. Do NOT use animate_photo or generate_video for\naudio-driven visualization.\n\nanimate_photo and generate_video produce audio natively via LTX 2.3 — never pre-generate\naudio for those tools. sound_to_video is only for when the audio IS the primary creative\ninput driving the video output.",
2288
+ "baseDescription": "sound_to_video creates audio-synced video from an audio source. Works with uploaded audio\nfiles (mp3, m4a, wav) OR previously generated music from generate_music (auto-detected).\n\nWhen the user asks to \"turn that song/music into a video\" after generate_music, use\nsound_to_video — it will automatically find the generated audio.\n\nFor music visualization (syncing video to a specific song or audio track), use the\ngenerate_music → sound_to_video pipeline. Do NOT use animate_photo or generate_video for\naudio-driven visualization.\n\nanimate_photo and generate_video produce audio natively via LTX 2.3 — never pre-generate\naudio for those tools. sound_to_video is only for when the audio IS the primary creative\ninput driving the video output.\n\nPERSONA VOICE CLIPS: Persona voice clips returned by resolve_personas are voice identity\nreferences for LTX Audio ID, not audio tracks to synchronize. If the user explicitly asks\nfor a registered/persona/reference voice or voice clone, call resolve_personas first, then\nuse generate_video with ltx23 and voicePersonaName when there is no image source, or\nanimate_photo with ltx23 and voicePersonaName when an image/source frame should be\nanimated. Do not call sound_to_video for a persona voice clip unless a separate uploaded\naudio track is the primary sync driver.",
2289
2289
  "parameterDocs": {
2290
2290
  "audioSource": "Uploaded audio file or reference to a prior generate_music result. Auto-detected when omitted after generate_music."
2291
2291
  }
@@ -2351,7 +2351,7 @@ const PROMPT_CONTRACTS = [
2351
2351
  "contractId": "resolve_personas_v1",
2352
2352
  "version": "1.0.0",
2353
2353
  "toolName": "resolve_personas",
2354
- "baseDescription": "resolve_personas is the required first step when the user explicitly names a saved Persona\nor says to use a Persona Image, Persona reference photo, Persona Voice, registered voice,\nor voice clone. Do not answer in prose, ask a follow-up, or finalize before calling this\ntool when a listed Persona name is present.\n\nDIRECT PERSONA IMAGE / VOICE VIDEO: If the user says to use the Persona image/reference\ndirectly/originally, call resolve_personas first, then call animate_photo using the injected\npersona photo as an uploaded image index. For one named Persona, use sourceImageIndex=-1\nor sourceImageIndices=[-1,...] for a multi-clip batch. If Persona Voice was explicitly\nrequested, set voicePersonaName to the exact resolved Persona name and use an LTX model.\nDo not call generate_video for Persona image/voice videos. Do not generate a new image first\nwhen the user explicitly requested the existing Persona image directly.\n\nMULTI-CLIP PERSONA BATCHES: If the user asks for several separate clips from the same\nPersona Image, make one animate_photo call after resolve_personas with repeated persona\nsource indices, one prompt per clip, and the requested per-clip duration. If the user asks\nto stitch the clips, call stitch_video with the returned video indices after animate_photo.",
2354
+ "baseDescription": "resolve_personas is the required first step when the user explicitly names a saved Persona\nor says to use a Persona Image, Persona reference photo, Persona Voice, registered voice,\nor voice clone. Do not answer in prose, ask a follow-up, or finalize before calling this\ntool when a listed Persona name is present.\n\nDIRECT PERSONA IMAGE / VOICE VIDEO: If the user says to use the Persona image/reference\ndirectly/originally, call resolve_personas first, then call animate_photo using the injected\npersona photo as an uploaded image index. For one named Persona, use sourceImageIndex=-1\nor sourceImageIndices=[-1,...] for a multi-clip batch. If Persona Voice was explicitly\nrequested, set voicePersonaName to the exact resolved Persona name and use ltx23. Use\nanimate_photo when an image/source frame should be animated. Use generate_video with\nltx23 when the user requested a persona voice video but no image source exists yet.\nDo not call sound_to_video for Persona voice clips; they are voice identity references,\nnot the audio track to synchronize. Do not generate a new image first when the user\nexplicitly requested the existing Persona image directly.\n\nMULTI-CLIP PERSONA BATCHES: If the user asks for several separate clips from the same\nPersona Image, make one animate_photo call after resolve_personas with repeated persona\nsource indices, one prompt per clip, and the requested per-clip duration. If the user asks\nto stitch the clips, call stitch_video with the returned video indices after animate_photo.",
2355
2355
  "parameterDocs": {
2356
2356
  "names": "Persona names to load. Use the exact listed Persona name; call this before any Persona image/voice video or image generation."
2357
2357
  }
package/llm.txt CHANGED
@@ -11,6 +11,11 @@ runtimes.
11
11
  npm install -g @sogni-ai/sogni-creative-agent-skill@latest
12
12
  sogni-agent --version
13
13
 
14
+ # Claude Code plugin (requires the CLI install above; the plugin shells out to sogni-agent).
15
+ # Run both slash commands from inside Claude Code:
16
+ # /plugin marketplace add Sogni-AI/sogni-creative-agent-skill
17
+ # /plugin install sogni-creative-agent@sogni
18
+
14
19
  # OpenClaw plugin
15
20
  openclaw plugins install sogni-creative-agent-skill
16
21
 
@@ -67,6 +72,9 @@ for `/v1/chat/completions` with rich creative-agent tools and sanitized
67
72
  message forwarding, or
68
73
  `sogni-agent --api-workflow --video-prompt "motion" "image prompt"`
69
74
  for durable `/v1/creative-agent/workflows` execution.
75
+ Use `SOGNI_SKILL_USE_SDK_TRANSPORT=1 sogni-agent --durable-chat "prompt"` for
76
+ durable `/v1/chat/runs` execution with SSE assistant deltas and per-job
77
+ progress / ETA / result events.
70
78
  Sogni Intelligence utilities are available with `--list-api-models`,
71
79
  `--get-api-model <id>`, `--task-profile general|coding|reasoning`,
72
80
  `--max-tokens <n>`, `--thinking` / `--no-thinking`, `--list-replays [n]`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sogni-ai/sogni-creative-agent-skill",
3
- "version": "3.1.1",
3
+ "version": "3.3.0",
4
4
  "description": "Sogni Creative Agent Skill: agent skill and CLI for Sogni AI image, video, and music generation.",
5
5
  "type": "module",
6
6
  "main": "sogni-agent.mjs",
@@ -62,11 +62,12 @@
62
62
  "openclaw.plugin.json",
63
63
  "env.mjs",
64
64
  "ssrf-guard.mjs",
65
+ "update-check.mjs",
65
66
  "generated/creative-agent-runtime.mjs",
66
67
  "sogni-agent.mjs"
67
68
  ],
68
69
  "dependencies": {
69
- "@sogni-ai/sogni-intelligence-client": "^2.2.6",
70
+ "@sogni-ai/sogni-intelligence-client": "^2.4.0",
70
71
  "execa": "^9.6.1",
71
72
  "json5": "^2.2.3",
72
73
  "sharp": "^0.34.5"
@@ -77,11 +78,11 @@
77
78
  ]
78
79
  },
79
80
  "devDependencies": {
80
- "@commitlint/cli": "^20.5.3",
81
- "@commitlint/config-conventional": "^20.5.3",
81
+ "@commitlint/cli": "^21.0.1",
82
+ "@commitlint/config-conventional": "^21.0.1",
82
83
  "@semantic-release/changelog": "^6.0.3",
83
84
  "@semantic-release/git": "^10.0.1",
84
85
  "husky": "^9.1.7",
85
- "semantic-release": "^24.2.9"
86
+ "semantic-release": "^25.0.3"
86
87
  }
87
88
  }
@@ -3,7 +3,7 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@sogni-ai/sogni-intelligence-client": "^2.2.6",
6
+ "@sogni-ai/sogni-intelligence-client": "^2.4.0",
7
7
  "execa": "^9.6.1",
8
8
  "json5": "^2.2.3",
9
9
  "sharp": "^0.34.5"
package/sogni-agent.mjs CHANGED
@@ -14,6 +14,13 @@ import sharp from 'sharp';
14
14
  import { getEnv, hasEnv } from './env.mjs';
15
15
  import { PACKAGE_VERSION } from './version.mjs';
16
16
  import { assertSafeUrl } from './ssrf-guard.mjs';
17
+ import {
18
+ INTERNAL_FLAG as UPDATE_CHECK_INTERNAL_FLAG,
19
+ runForegroundCheck as runUpdateCheckForeground,
20
+ maybeSpawnBackgroundCheck as maybeSpawnUpdateCheck,
21
+ getQueuedNotice as getUpdateCheckNotice,
22
+ runSelfUpdate as runSogniSelfUpdate,
23
+ } from './update-check.mjs';
17
24
  import {
18
25
  LTX23_WORKFLOW_MODELS,
19
26
  PUBLIC_SKILL_DEFAULT_TOOL_DEFINITIONS,
@@ -56,6 +63,14 @@ import {
56
63
  redactPayload,
57
64
  redactRunRecord
58
65
  } from '@sogni-ai/sogni-intelligence-client/replay';
66
+ import {
67
+ extractToolCallProgressUpdate
68
+ } from '@sogni-ai/sogni-intelligence-client/chatRun';
69
+ import {
70
+ SEEDANCE_REFERENCE_LIMITS,
71
+ SeedanceReferenceLimitError,
72
+ validateSeedanceReferenceCounts
73
+ } from '@sogni-ai/sogni-intelligence-client/tools';
59
74
 
60
75
  const require = createRequire(import.meta.url);
61
76
  const rootClientModule = process.env.SOGNI_AGENT_TEST_STATE_PATH
@@ -121,6 +136,26 @@ const IS_OPENCLAW_INVOCATION = Boolean(getEnv('OPENCLAW_PLUGIN_CONFIG'));
121
136
  const RAW_ARGS = process.argv.slice(2);
122
137
  const CLI_WANTS_JSON = RAW_ARGS.includes('--json');
123
138
  const JSON_ERROR_MODE = CLI_WANTS_JSON || IS_OPENCLAW_INVOCATION;
139
+
140
+ // --- Update-check entry points --------------------------------------------
141
+ // Internal mode: the detached background child that fetches the npm registry.
142
+ if (RAW_ARGS[0] === UPDATE_CHECK_INTERNAL_FLAG) {
143
+ await runUpdateCheckForeground({ currentVersion: PACKAGE_VERSION });
144
+ process.exit(0);
145
+ }
146
+ // User-facing subcommand: `sogni-agent self-update`
147
+ if (RAW_ARGS[0] === 'self-update') {
148
+ process.exit(runSogniSelfUpdate({}));
149
+ }
150
+ // Fire-and-forget background check (no-op when throttled or skipped)
151
+ try { maybeSpawnUpdateCheck({ cliPath: process.argv[1] }); } catch { /* never break the CLI */ }
152
+ // Trailing notice on exit, if a newer version is on file
153
+ process.on('exit', () => {
154
+ try {
155
+ const notice = getUpdateCheckNotice({ currentVersion: PACKAGE_VERSION });
156
+ if (notice) process.stderr.write(notice + '\n');
157
+ } catch { /* never break exit */ }
158
+ });
124
159
  const SOCKET_EVENT_SUBSCRIPTIONS = Object.freeze({
125
160
  modelAvailability: false
126
161
  });
@@ -1108,12 +1143,14 @@ const options = {
1108
1143
  angles360Video: null,
1109
1144
  refImage: null, // Reference image for video (start frame)
1110
1145
  refImageEnd: null, // End frame for video interpolation
1111
- refAudio: null, // Uploaded/generated audio for ia2v/a2v, or s2v lip-sync
1146
+ refAudio: null, // Uploaded/generated audio for ia2v/a2v, or s2v lip-sync (primary)
1147
+ refAudios: [], // Additional Seedance loose audio refs; first --ref-audio fills refAudio, subsequent calls append here
1112
1148
  audioStart: null, // Optional start offset into reference audio
1113
1149
  audioDuration: null, // Optional duration slice for reference audio
1114
1150
  referenceAudioIdentity: null, // Voice identity reference for LTX native audio
1115
1151
  voicePersonaName: null,
1116
- refVideo: null, // Reference video for animate workflows
1152
+ refVideo: null, // Reference video for animate workflows (primary)
1153
+ refVideos: [], // Additional Seedance loose video refs; first --ref-video fills refVideo, subsequent calls append here
1117
1154
  videoStart: null, // Optional start offset into reference video
1118
1155
  contextImages: [], // Context images for image editing
1119
1156
  looping: false, // Create looping video (i2v only): generate A→B then B→A and concatenate
@@ -1238,11 +1275,13 @@ const cliSet = {
1238
1275
  refImage: false,
1239
1276
  refImageEnd: false,
1240
1277
  refAudio: false,
1278
+ refAudios: false,
1241
1279
  audioStart: false,
1242
1280
  audioDuration: false,
1243
1281
  referenceAudioIdentity: false,
1244
1282
  voicePersonaName: false,
1245
1283
  refVideo: false,
1284
+ refVideos: false,
1246
1285
  videoStart: false,
1247
1286
  context: false,
1248
1287
  looping: false,
@@ -1529,8 +1568,13 @@ for (let i = 0; i < args.length; i++) {
1529
1568
  } else if (arg === '--ref-audio' || arg === '--audio') {
1530
1569
  const raw = requireFlagValue(args, i, arg);
1531
1570
  i++;
1532
- options.refAudio = raw;
1533
- cliSet.refAudio = true;
1571
+ if (!options.refAudio) {
1572
+ options.refAudio = raw;
1573
+ cliSet.refAudio = true;
1574
+ } else {
1575
+ options.refAudios.push(raw);
1576
+ cliSet.refAudios = true;
1577
+ }
1534
1578
  } else if (arg === '--audio-start') {
1535
1579
  const raw = requireFlagValue(args, i, arg);
1536
1580
  i++;
@@ -1554,8 +1598,13 @@ for (let i = 0; i < args.length; i++) {
1554
1598
  } else if (arg === '--ref-video') {
1555
1599
  const raw = requireFlagValue(args, i, arg);
1556
1600
  i++;
1557
- options.refVideo = raw;
1558
- cliSet.refVideo = true;
1601
+ if (!options.refVideo) {
1602
+ options.refVideo = raw;
1603
+ cliSet.refVideo = true;
1604
+ } else {
1605
+ options.refVideos.push(raw);
1606
+ cliSet.refVideos = true;
1607
+ }
1559
1608
  } else if (arg === '--video-start' || arg === '--video-start-offset') {
1560
1609
  const raw = requireFlagValue(args, i, arg);
1561
1610
  i++;
@@ -1947,6 +1996,9 @@ for (let i = 0; i < args.length; i++) {
1947
1996
  options.showBalance = true;
1948
1997
  } else if (arg === '--version' || arg === '-V') {
1949
1998
  options.showVersion = true;
1999
+ } else if (arg === '--no-update-check') {
2000
+ // Update-check opt-out handled at module load; no-op here so the parser
2001
+ // doesn't reject it as an unknown option.
1950
2002
  } else if (arg === '--help') {
1951
2003
  console.log(`
1952
2004
  sogni-agent - Generate images, videos, and music using Sogni AI
@@ -2015,14 +2067,30 @@ Video Options:
2015
2067
  --auto-resize-assets Auto-resize video reference assets (default)
2016
2068
  --no-auto-resize-assets Disable auto-resize for video assets
2017
2069
  --estimate-video-cost Estimate video cost and exit
2018
- --ref <path|url> Reference image for video (start frame)
2019
- --ref-end <path|url> End frame for interpolation/morphing
2020
- --ref-audio <path|url> Uploaded/generated audio for ia2v/a2v, or s2v lip-sync
2070
+ --ref <path|url> Reference image for video (start/first frame on Seedance)
2071
+ --ref-end <path|url> End frame for interpolation/morphing (last frame on Seedance)
2072
+ --ref-audio <path|url> Audio reference. Repeatable on Seedance models (up to 3 total);
2073
+ first entry is the primary, extras must be HTTPS URLs in CLI
2074
+ direct-gen (use --api-chat for multi local-file uploads).
2075
+ On LTX/WAN: single primary only (for ia2v/a2v/s2v lip-sync).
2021
2076
  --audio-start <sec> Start offset into --ref-audio for audio-driven clips
2022
2077
  --audio-duration <sec> Duration slice from --ref-audio
2023
2078
  --reference-audio-identity <path> Voice identity clip for LTX native audio
2024
2079
  --voice-persona <name> Use saved persona voice clip as LTX voice identity
2025
- --ref-video <path|url> Reference video for animate/v2v workflows
2080
+ --ref-video <path|url> Video reference. Repeatable on Seedance models (up to 3 total);
2081
+ first entry is the primary, extras must be HTTPS URLs in CLI
2082
+ direct-gen. On LTX/WAN: single primary for animate/v2v workflows.
2083
+
2084
+ Seedance Reference Modes (mutually exclusive on seedance2 / seedance2-fast):
2085
+ - DEDICATED FRAME MODE: --ref (first frame) and/or --ref-end (last frame).
2086
+ Best when you want canonical first/last frame anchoring; max 2 images.
2087
+ - LOOSE REFERENCE MODE: -c/--context image refs plus optional --ref-audio /
2088
+ --ref-video extras. Anchor frame intent in the prompt with @Image1, @Image2,
2089
+ @Video1, @Audio1 etc. (e.g. "Use @Image1 as the opening shot reference").
2090
+ Up to 9 image / 3 video / 3 audio / 12 total references per video request.
2091
+ Combining --ref/--ref-end with -c/--context on Seedance is rejected client-side.
2092
+ All three modalities pull caps from the canonical
2093
+ @sogni-ai/sogni-protocol seedance-reference-limits catalog.
2026
2094
  --video-start <sec> Start offset into --ref-video for segmented V2V/animate
2027
2095
  --controlnet-name <n> ControlNet type for v2v: canny|pose|depth|detailer
2028
2096
  --controlnet-strength <n> ControlNet strength for v2v (0.0-1.0, default: 0.8)
@@ -2076,6 +2144,8 @@ General:
2076
2144
  --token-type <type> Token type: spark|sogni|auto (default: spark, auto retries with alternate)
2077
2145
  --balance, --balances Show SPARK/SOGNI balances and exit
2078
2146
  --version, -V Show sogni-agent version and exit
2147
+ --no-update-check Skip the once-daily npm update check for this run
2148
+ self-update Upgrade sogni-agent in place (npm/pnpm/yarn/bun auto-detected)
2079
2149
  --extract-last-frame <video> <image> Extract last frame from a video (safe ffmpeg wrapper)
2080
2150
  --concat-videos <out> <clips...> Concatenate video clips (safe ffmpeg wrapper, min 2 clips)
2081
2151
  --concat-audio <path> Optional audio track to mux over --concat-videos output
@@ -2916,8 +2986,47 @@ if (options.video) {
2916
2986
  if (options.videoStart !== null && !options.refVideo) {
2917
2987
  fatalCliError('--video-start requires --ref-video.', { code: 'INVALID_ARGUMENT' });
2918
2988
  }
2919
- if (isSeedanceVideo && options.refAudio && !options.refImage && !options.refImageEnd && !options.refVideo) {
2920
- fatalCliError('Seedance audio references require --ref or --ref-video.', { code: 'INVALID_ARGUMENT' });
2989
+ if (isSeedanceVideo && options.refAudio && !options.refImage && !options.refImageEnd && !options.refVideo
2990
+ && (!Array.isArray(options.contextImages) || options.contextImages.length === 0)) {
2991
+ fatalCliError('Seedance audio references require --ref, --ref-video, or -c/--context image refs.', { code: 'INVALID_ARGUMENT' });
2992
+ }
2993
+
2994
+ // Seedance reference modes are mutually exclusive:
2995
+ // - DEDICATED FRAME MODE: --ref (first frame) and/or --ref-end (last frame).
2996
+ // Up to 2 images; the platform pins them as parameter-mode firstFrame/lastFrame.
2997
+ // - LOOSE REFERENCE MODE: -c/--context (repeatable image refs), --ref-audio extras,
2998
+ // --ref-video extras. Up to 9 images / 3 videos / 3 audios / 12 total.
2999
+ // Anchor frame intent in the prompt with @Image1 / @Video1 / @Audio1 etc.
3000
+ // Mixing dedicated frames with loose image refs is rejected at sogni-socket
3001
+ // (jobsController.js) so we catch it client-side with a clearer message.
3002
+ if (isSeedanceVideo
3003
+ && (options.refImage || options.refImageEnd)
3004
+ && Array.isArray(options.contextImages) && options.contextImages.length > 0) {
3005
+ fatalCliError(
3006
+ 'Seedance reference modes are mutually exclusive: --ref/--ref-end (dedicated first/last frame) cannot be combined with -c/--context (loose image references). '
3007
+ + 'Pick one: use --ref/--ref-end for first-class first-frame/last-frame anchoring (max 2 images), '
3008
+ + 'or use -c/--context (plus optional @Image1/@Image2 prompt language) for up to 9 loose image references.',
3009
+ { code: 'INVALID_ARGUMENT', details: {
3010
+ dedicatedFrames: [options.refImage, options.refImageEnd].filter(Boolean),
3011
+ looseImageRefs: options.contextImages,
3012
+ } },
3013
+ );
3014
+ }
3015
+ // Non-Seedance video models do not understand multi-ref audio/video extras —
3016
+ // they only support a single primary --ref-audio / --ref-video each.
3017
+ if (!isSeedanceVideo) {
3018
+ if (Array.isArray(options.refAudios) && options.refAudios.length > 0) {
3019
+ fatalCliError('Multiple --ref-audio entries are only supported for Seedance models (seedance2, seedance2-fast).', {
3020
+ code: 'INVALID_ARGUMENT',
3021
+ details: { model: options.model, extras: options.refAudios },
3022
+ });
3023
+ }
3024
+ if (Array.isArray(options.refVideos) && options.refVideos.length > 0) {
3025
+ fatalCliError('Multiple --ref-video entries are only supported for Seedance models (seedance2, seedance2-fast).', {
3026
+ code: 'INVALID_ARGUMENT',
3027
+ details: { model: options.model, extras: options.refVideos },
3028
+ });
3029
+ }
2921
3030
  }
2922
3031
 
2923
3032
  if (options.referenceAudioIdentity && !['t2v', 'i2v'].includes(options.videoWorkflow)) {
@@ -3246,8 +3355,9 @@ function apiRequestHeaders(apiKey, extra = {}) {
3246
3355
  * Phase 6 P0 — SDK transport dispatch for hosted workflow operations.
3247
3356
  *
3248
3357
  * When `SOGNI_SKILL_USE_SDK_TRANSPORT=1` is set, route hosted workflow
3249
- * start / get / list / events / cancel through `@sogni-ai/sogni-client`
3250
- * via the SSRF-validated `SogniHostedClientFactory` in
3358
+ * start / get / list / events / cancel through
3359
+ * `@sogni-ai/sogni-intelligence-client`'s SDK-backed client via the
3360
+ * SSRF-validated `SogniHostedClientFactory` in
3251
3361
  * `sogni-hosted-client.mjs`. Otherwise fall back to the legacy
3252
3362
  * `fetchApiJson` path so existing users on older SDK versions are
3253
3363
  * unaffected.
@@ -3325,8 +3435,9 @@ async function dispatchWorkflowActionViaSdk(action, apiKey, params) {
3325
3435
  * Phase 6 P0 — SDK transport dispatch for hosted chat completions.
3326
3436
  *
3327
3437
  * When `SOGNI_SKILL_USE_SDK_TRANSPORT=1` is set, route synchronous
3328
- * hosted chat through `@sogni-ai/sogni-client` via the SSRF-validated
3329
- * factory. The SDK's `chat.hosted.create` accepts the same field
3438
+ * hosted chat through `@sogni-ai/sogni-intelligence-client`'s SDK-backed
3439
+ * client via the SSRF-validated factory. The SDK's `chat.hosted.create`
3440
+ * accepts the same field
3330
3441
  * names the legacy fetch sends (`model`, `messages`, `temperature`,
3331
3442
  * `max_tokens`, `token_type`, `app_source`, `sogni_tools`,
3332
3443
  * `sogni_tool_execution`, `task_profile`, `chat_template_kwargs`,
@@ -3464,8 +3575,14 @@ function getApiModeMediaReferences() {
3464
3575
  if (options.refImage) refs.push({ flag: '--ref', value: options.refImage, kind: 'image' });
3465
3576
  if (options.refImageEnd) refs.push({ flag: '--ref-end', value: options.refImageEnd, kind: 'image' });
3466
3577
  if (options.refAudio) refs.push({ flag: '--ref-audio', value: options.refAudio, kind: 'audio' });
3578
+ for (const value of options.refAudios || []) {
3579
+ if (value) refs.push({ flag: '--ref-audio', value, kind: 'audio' });
3580
+ }
3467
3581
  if (options.referenceAudioIdentity) refs.push({ flag: '--reference-audio-identity', value: options.referenceAudioIdentity, kind: 'audio' });
3468
3582
  if (options.refVideo) refs.push({ flag: '--ref-video', value: options.refVideo, kind: 'video' });
3583
+ for (const value of options.refVideos || []) {
3584
+ if (value) refs.push({ flag: '--ref-video', value, kind: 'video' });
3585
+ }
3469
3586
  return refs;
3470
3587
  }
3471
3588
 
@@ -4014,6 +4131,17 @@ async function runApiChatDurable(log, { apiKey, body }) {
4014
4131
  }
4015
4132
  if (!options.json) log(`Durable chat run started: ${runId}`);
4016
4133
 
4134
+ // Per-job tool_call_progress dedupe state. The sogni-api throttled
4135
+ // emitter sends 1 Hz `jobETA` countdowns + per-step progress
4136
+ // ticks per job; we log only when the value actually changes
4137
+ // (and only in non-JSON CLI mode) so a 16-image batch doesn't
4138
+ // pour ~16 lines/sec into the log file.
4139
+ const perJobLogState = new Map();
4140
+ const logJobUpdate = (line) => {
4141
+ if (options.json) return;
4142
+ log(line);
4143
+ };
4144
+
4017
4145
  for await (const event of helpers.sdkChatRunsStreamEvents(client, runId, {})) {
4018
4146
  const type = event?.type || event?.event || '';
4019
4147
  const payload = event?.data || event;
@@ -4028,6 +4156,50 @@ async function runApiChatDurable(log, { apiKey, body }) {
4028
4156
  process.stdout.write(delta);
4029
4157
  }
4030
4158
  }
4159
+ // Per-job progress / ETA / completion / error log lines for
4160
+ // CLI watchers. The sogni-api `tool_call_progress` SSE event
4161
+ // packs `jobIndex` + per-job fields (`jobProgress`,
4162
+ // `jobEtaSeconds`, `resultUrl`, `jobError`) for vendor-emulated
4163
+ // jobs (GPT, Seedance — 1 Hz `jobETA` heartbeat from
4164
+ // sogni-socket) and real workers (per-step progress).
4165
+ // Untouched payloads from older sogni-api builds simply lack
4166
+ // `jobIndex` and skip this block — forward-compatible.
4167
+ if (type === 'tool_call_progress' && payload && typeof payload === 'object') {
4168
+ const {
4169
+ jobIndex,
4170
+ jobProgress,
4171
+ jobEtaSeconds,
4172
+ resultUrl,
4173
+ jobError,
4174
+ } = extractToolCallProgressUpdate(payload);
4175
+ if (jobIndex !== undefined) {
4176
+ const state = perJobLogState.get(jobIndex) ?? {};
4177
+ if (jobError && state.error !== jobError) {
4178
+ logJobUpdate(`[job ${jobIndex}] error: ${jobError}`);
4179
+ state.error = jobError;
4180
+ } else if (resultUrl && state.resultUrl !== resultUrl) {
4181
+ logJobUpdate(`[job ${jobIndex}] done${jobProgress !== undefined ? ` (${Math.round(jobProgress * 100)}%)` : ''} → ${resultUrl}`);
4182
+ state.resultUrl = resultUrl;
4183
+ state.progress = jobProgress ?? state.progress;
4184
+ } else if (jobProgress !== undefined || jobEtaSeconds !== undefined) {
4185
+ // Dedupe: only emit when progress moved >=5% or ETA changed.
4186
+ const pctBefore = state.progress !== undefined ? Math.round(state.progress * 100) : -1;
4187
+ const pctNow = jobProgress !== undefined ? Math.round(jobProgress * 100) : pctBefore;
4188
+ const progressChanged = jobProgress !== undefined && Math.abs(pctNow - pctBefore) >= 5;
4189
+ const etaChanged = jobEtaSeconds !== undefined && jobEtaSeconds !== state.eta;
4190
+ if (progressChanged || etaChanged) {
4191
+ const parts = [`[job ${jobIndex}]`];
4192
+ if (jobProgress !== undefined) parts.push(`${pctNow}%`);
4193
+ else if (state.progress !== undefined) parts.push(`${pctBefore}%`);
4194
+ if (jobEtaSeconds !== undefined) parts.push(`(${jobEtaSeconds}s)`);
4195
+ logJobUpdate(parts.join(' '));
4196
+ if (jobProgress !== undefined) state.progress = jobProgress;
4197
+ if (jobEtaSeconds !== undefined) state.eta = jobEtaSeconds;
4198
+ }
4199
+ }
4200
+ perJobLogState.set(jobIndex, state);
4201
+ }
4202
+ }
4031
4203
  const eventToolCalls =
4032
4204
  payload?.toolCalls
4033
4205
  || payload?.tool_calls
@@ -5306,6 +5478,51 @@ async function appendSafeSeedanceReferenceUrl(target, pathOrUrl, label) {
5306
5478
  return true;
5307
5479
  }
5308
5480
 
5481
+ // Effective Seedance reference counts for the current `options` snapshot.
5482
+ // Mirrors the per-modality bookkeeping sogni-chat does in
5483
+ // uploadedModalityReferenceIndices(...) (chatService.ts ~6149), translated to
5484
+ // the skill's primary + extras CLI shape:
5485
+ // images = refImage + refImageEnd + contextImages (loose Seedance @ImageN refs)
5486
+ // audios = refAudio + refAudios (extras)
5487
+ // videos = refVideo + refVideos (extras)
5488
+ function effectiveSeedanceReferenceCounts() {
5489
+ const images =
5490
+ (options.refImage ? 1 : 0)
5491
+ + (options.refImageEnd ? 1 : 0)
5492
+ + (Array.isArray(options.contextImages) ? options.contextImages.length : 0);
5493
+ const audios =
5494
+ (options.refAudio ? 1 : 0)
5495
+ + (Array.isArray(options.refAudios) ? options.refAudios.length : 0);
5496
+ const videos =
5497
+ (options.refVideo ? 1 : 0)
5498
+ + (Array.isArray(options.refVideos) ? options.refVideos.length : 0);
5499
+ return { images, audios, videos };
5500
+ }
5501
+
5502
+ // Wraps the shared validateSeedanceReferenceCounts() so a thrown
5503
+ // SeedanceReferenceLimitError is re-raised as a CLI fatal error with the same
5504
+ // human message the hosted chat surfaces. Source of truth for the numeric caps
5505
+ // (9 / 3 / 3 / 12) is @sogni-ai/sogni-protocol's seedance-reference-limits
5506
+ // catalog, surfaced through @sogni-ai/sogni-intelligence-client/tools.
5507
+ function enforceSeedanceReferenceCaps() {
5508
+ try {
5509
+ validateSeedanceReferenceCounts(effectiveSeedanceReferenceCounts());
5510
+ } catch (err) {
5511
+ if (err instanceof SeedanceReferenceLimitError) {
5512
+ fatalCliError(err.message, {
5513
+ code: err.code,
5514
+ details: {
5515
+ limitKind: err.limitKind,
5516
+ requestedCount: err.requestedCount,
5517
+ maxCount: err.maxCount,
5518
+ limits: SEEDANCE_REFERENCE_LIMITS,
5519
+ },
5520
+ });
5521
+ }
5522
+ throw err;
5523
+ }
5524
+ }
5525
+
5309
5526
  function resolveMultiAngleOutputConfig(outputPath, outputFormat) {
5310
5527
  if (!outputPath) return null;
5311
5528
  const ext = extname(outputPath);
@@ -6499,6 +6716,11 @@ async function main() {
6499
6716
  if (options.refVideo) log(`Reference video: ${options.refVideo}`);
6500
6717
 
6501
6718
  const isSeedanceVideo = isSeedanceModel(options.model);
6719
+ if (isSeedanceVideo) {
6720
+ // Source of truth: @sogni-ai/sogni-protocol catalogs/seedance-reference-limits.json
6721
+ // surfaced through @sogni-ai/sogni-intelligence-client/tools.
6722
+ enforceSeedanceReferenceCaps();
6723
+ }
6502
6724
  const seedanceReferenceImageUrls = [];
6503
6725
  const seedanceReferenceVideoUrls = [];
6504
6726
  const seedanceReferenceAudioUrls = [];
@@ -6518,6 +6740,45 @@ async function main() {
6518
6740
  && options.videoStart === null
6519
6741
  && await appendSafeSeedanceReferenceUrl(seedanceReferenceVideoUrls, options.refVideo, 'Reference video');
6520
6742
 
6743
+ // Seedance loose-reference extras: -c/--context images beyond start/end,
6744
+ // plus repeated --ref-audio / --ref-video entries past the first. The
6745
+ // Sogni Client SDK accepts only URL arrays for these (createJobRequestMessage),
6746
+ // so extras MUST be HTTPS URLs. For multi-file local uploads, use --api-chat /
6747
+ // --durable-chat where the LLM upload pipeline handles per-file uploads.
6748
+ if (isSeedanceVideo) {
6749
+ for (const ctxImage of (Array.isArray(options.contextImages) ? options.contextImages : [])) {
6750
+ if (!ctxImage) continue;
6751
+ if (!isHttpsUrl(ctxImage)) {
6752
+ fatalCliError(
6753
+ `Seedance extra image reference "${ctxImage}" must be an HTTPS URL. ` +
6754
+ 'Local file uploads beyond --ref / --ref-end are only supported in --api-chat / --durable-chat mode.',
6755
+ { code: 'INVALID_ARGUMENT', details: { flag: '-c/--context', value: ctxImage } },
6756
+ );
6757
+ }
6758
+ await appendSafeSeedanceReferenceUrl(seedanceReferenceImageUrls, ctxImage, 'Seedance image reference');
6759
+ }
6760
+ for (const extraAudio of options.refAudios) {
6761
+ if (!isHttpsUrl(extraAudio)) {
6762
+ fatalCliError(
6763
+ `Additional --ref-audio "${extraAudio}" must be an HTTPS URL. ` +
6764
+ 'Local file uploads beyond the primary --ref-audio are only supported in --api-chat / --durable-chat mode.',
6765
+ { code: 'INVALID_ARGUMENT', details: { flag: '--ref-audio', value: extraAudio } },
6766
+ );
6767
+ }
6768
+ await appendSafeSeedanceReferenceUrl(seedanceReferenceAudioUrls, extraAudio, 'Seedance audio reference');
6769
+ }
6770
+ for (const extraVideo of options.refVideos) {
6771
+ if (!isHttpsUrl(extraVideo)) {
6772
+ fatalCliError(
6773
+ `Additional --ref-video "${extraVideo}" must be an HTTPS URL. ` +
6774
+ 'Local file uploads beyond the primary --ref-video are only supported in --api-chat / --durable-chat mode.',
6775
+ { code: 'INVALID_ARGUMENT', details: { flag: '--ref-video', value: extraVideo } },
6776
+ );
6777
+ }
6778
+ await appendSafeSeedanceReferenceUrl(seedanceReferenceVideoUrls, extraVideo, 'Seedance video reference');
6779
+ }
6780
+ }
6781
+
6521
6782
  let imageBuffer = options.refImage && !useRefImageUrl ? await fetchMediaBuffer(options.refImage) : undefined;
6522
6783
  let endImageBuffer = options.refImageEnd && !useRefImageEndUrl ? await fetchMediaBuffer(options.refImageEnd) : undefined;
6523
6784
  let audioBuffer = options.refAudio && !useRefAudioUrl ? await fetchMediaBuffer(options.refAudio) : undefined;
@@ -0,0 +1,303 @@
1
+ /**
2
+ * sogni-agent update check — trailing-notification style.
3
+ *
4
+ * Public API:
5
+ * shouldSkipForEnvironment(opts) → boolean (pure)
6
+ * compareSemver(a, b) → -1|0|1 (pure)
7
+ * detectPackageManager(env) → { manager, installCmd }
8
+ * formatUpdateNotice(opts) → string (pure)
9
+ * readState(path) → state | null
10
+ * writeState(path, state) → void
11
+ * runForegroundCheck(opts) → Promise<void> (used by --__update-check)
12
+ * maybeSpawnBackgroundCheck(opts) → 'spawned' | 'skipped' | 'fresh'
13
+ * getQueuedNotice(opts) → string | null
14
+ * runSelfUpdate(opts) → number (exit code)
15
+ */
16
+
17
+ import { spawn, spawnSync } from 'child_process';
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
19
+ import { dirname, join } from 'path';
20
+ import { homedir } from 'os';
21
+ import https from 'https';
22
+
23
+ export const PACKAGE_NAME = '@sogni-ai/sogni-creative-agent-skill';
24
+ export const DEFAULT_STATE_PATH = join(homedir(), '.config', 'sogni', 'update-check.json');
25
+ export const DEFAULT_THROTTLE_MS = 24 * 60 * 60 * 1000; // 24h
26
+ const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
27
+ const REGISTRY_TIMEOUT_MS = 1500;
28
+ const MAX_RESPONSE_BYTES = 1024 * 1024;
29
+ const INTERNAL_FLAG = '--__update-check';
30
+
31
+ export { INTERNAL_FLAG };
32
+
33
+ // ---------- pure helpers ----------
34
+
35
+ function parseSemverPart(value) {
36
+ const [main, prerelease] = String(value).split('-', 2);
37
+ const nums = main.split('.').map((n) => Number.parseInt(n, 10));
38
+ if (nums.length !== 3 || nums.some((n) => !Number.isFinite(n) || n < 0)) return null;
39
+ return { nums, prerelease: prerelease || '' };
40
+ }
41
+
42
+ export function compareSemver(a, b) {
43
+ const pa = parseSemverPart(a);
44
+ const pb = parseSemverPart(b);
45
+ if (!pa || !pb) return 0;
46
+ for (let i = 0; i < 3; i++) {
47
+ if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] < pb.nums[i] ? -1 : 1;
48
+ }
49
+ if (pa.prerelease === pb.prerelease) return 0;
50
+ if (!pa.prerelease) return 1;
51
+ if (!pb.prerelease) return -1;
52
+ return pa.prerelease < pb.prerelease ? -1 : 1;
53
+ }
54
+
55
+ export function detectPackageManager(env = process.env) {
56
+ const ua = env.npm_config_user_agent || '';
57
+ if (ua.startsWith('pnpm/')) {
58
+ return { manager: 'pnpm', installCmd: `pnpm add -g ${PACKAGE_NAME}` };
59
+ }
60
+ if (ua.startsWith('yarn/')) {
61
+ return { manager: 'yarn', installCmd: `yarn global add ${PACKAGE_NAME}` };
62
+ }
63
+ if (ua.startsWith('bun/')) {
64
+ return { manager: 'bun', installCmd: `bun add -g ${PACKAGE_NAME}` };
65
+ }
66
+ return { manager: 'npm', installCmd: `npm install -g ${PACKAGE_NAME}` };
67
+ }
68
+
69
+ export function shouldSkipForEnvironment({
70
+ argv = process.argv,
71
+ env = process.env,
72
+ stderr = process.stderr,
73
+ cliPath = process.argv[1] || '',
74
+ } = {}) {
75
+ if (Array.isArray(argv) && argv.includes('--no-update-check')) return true;
76
+ if (env.SOGNI_NO_UPDATE_CHECK === '1' || env.SOGNI_NO_UPDATE_CHECK === 'true') return true;
77
+ if (env.NO_UPDATE_NOTIFIER === '1' || env.NO_UPDATE_NOTIFIER === 'true') return true;
78
+ if (env.CI) return true;
79
+ if (env.SOGNI_AGENT_TEST_STATE_PATH) return true;
80
+ if (env.OPENCLAW_PLUGIN_CONFIG) return true;
81
+ if (env.NODE_ENV === 'test') return true;
82
+ if (env.npm_lifecycle_event) return true; // running under `npm <script>`
83
+ if (Array.isArray(argv) && argv.includes('--json')) return true;
84
+ if (stderr && stderr.isTTY === false) return true;
85
+ // Dev / source checkout: CLI directory contains .git
86
+ if (cliPath) {
87
+ try {
88
+ const cliDir = dirname(cliPath);
89
+ if (existsSync(join(cliDir, '.git'))) return true;
90
+ } catch {
91
+ // ignore
92
+ }
93
+ }
94
+ return false;
95
+ }
96
+
97
+ export function formatUpdateNotice({
98
+ currentVersion,
99
+ latestVersion,
100
+ installCmd,
101
+ useColor,
102
+ } = {}) {
103
+ const color = useColor !== false && !process.env.NO_COLOR && process.stderr.isTTY;
104
+ const c = {
105
+ dim: color ? '\x1b[2m' : '',
106
+ bold: color ? '\x1b[1m' : '',
107
+ yellow: color ? '\x1b[33m' : '',
108
+ cyan: color ? '\x1b[36m' : '',
109
+ reset: color ? '\x1b[0m' : '',
110
+ };
111
+ const headline = `Update available ${c.dim}${currentVersion}${c.reset} → ${c.bold}${c.yellow}${latestVersion}${c.reset}`;
112
+ const cta = `Run ${c.cyan}${installCmd}${c.reset} to update`;
113
+ const tip = `${c.dim}(or run ${c.reset}${c.cyan}sogni-agent self-update${c.reset}${c.dim}, disable with --no-update-check)${c.reset}`;
114
+ return ['', headline, cta, tip, ''].join('\n');
115
+ }
116
+
117
+ // ---------- state file ----------
118
+
119
+ export function readState(path = DEFAULT_STATE_PATH) {
120
+ try {
121
+ if (!existsSync(path)) return null;
122
+ const raw = readFileSync(path, 'utf8');
123
+ const parsed = JSON.parse(raw);
124
+ if (!parsed || typeof parsed !== 'object') return null;
125
+ return parsed;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ export function writeState(path, state) {
132
+ try {
133
+ const dir = dirname(path);
134
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
135
+ writeFileSync(path, JSON.stringify(state, null, 2));
136
+ } catch {
137
+ // best-effort; never throw
138
+ }
139
+ }
140
+
141
+ export function clearState(path = DEFAULT_STATE_PATH) {
142
+ try {
143
+ if (existsSync(path)) unlinkSync(path);
144
+ } catch {
145
+ // ignore
146
+ }
147
+ }
148
+
149
+ // ---------- network ----------
150
+
151
+ function fetchLatestVersion({ url = REGISTRY_URL, timeoutMs = REGISTRY_TIMEOUT_MS } = {}) {
152
+ return new Promise((resolve, reject) => {
153
+ let settled = false;
154
+ const finish = (fn, value) => {
155
+ if (settled) return;
156
+ settled = true;
157
+ fn(value);
158
+ };
159
+ let req;
160
+ try {
161
+ req = https.get(url, { headers: { accept: 'application/json' } }, (res) => {
162
+ if (res.statusCode !== 200) {
163
+ res.resume();
164
+ finish(reject, new Error(`registry status ${res.statusCode}`));
165
+ return;
166
+ }
167
+ let received = 0;
168
+ const chunks = [];
169
+ res.on('data', (chunk) => {
170
+ received += chunk.length;
171
+ if (received > MAX_RESPONSE_BYTES) {
172
+ res.destroy();
173
+ finish(reject, new Error('registry response too large'));
174
+ return;
175
+ }
176
+ chunks.push(chunk);
177
+ });
178
+ res.on('end', () => {
179
+ try {
180
+ const body = Buffer.concat(chunks).toString('utf8');
181
+ const parsed = JSON.parse(body);
182
+ if (parsed && typeof parsed.version === 'string') {
183
+ finish(resolve, parsed.version);
184
+ } else {
185
+ finish(reject, new Error('registry response missing version'));
186
+ }
187
+ } catch (err) {
188
+ finish(reject, err);
189
+ }
190
+ });
191
+ res.on('error', (err) => finish(reject, err));
192
+ });
193
+ } catch (err) {
194
+ finish(reject, err);
195
+ return;
196
+ }
197
+ req.setTimeout(timeoutMs, () => {
198
+ req.destroy(new Error('registry timeout'));
199
+ });
200
+ req.on('error', (err) => finish(reject, err));
201
+ });
202
+ }
203
+
204
+ // ---------- foreground (child) check ----------
205
+
206
+ export async function runForegroundCheck({
207
+ currentVersion,
208
+ statePath = DEFAULT_STATE_PATH,
209
+ url = REGISTRY_URL,
210
+ timeoutMs = REGISTRY_TIMEOUT_MS,
211
+ fetcher = fetchLatestVersion,
212
+ now = Date.now,
213
+ } = {}) {
214
+ try {
215
+ const latest = await fetcher({ url, timeoutMs });
216
+ writeState(statePath, {
217
+ lastCheckedAt: now(),
218
+ lastKnownLatest: latest,
219
+ currentVersion: currentVersion || null,
220
+ });
221
+ } catch {
222
+ // Still record the attempt timestamp so we don't hammer the registry
223
+ // when offline. Keep any previously-known latest version so the user
224
+ // still sees the notice for an older known update.
225
+ const prev = readState(statePath) || {};
226
+ writeState(statePath, {
227
+ lastCheckedAt: now(),
228
+ lastKnownLatest: prev.lastKnownLatest || null,
229
+ currentVersion: currentVersion || null,
230
+ });
231
+ }
232
+ }
233
+
234
+ // ---------- parent helpers ----------
235
+
236
+ export function maybeSpawnBackgroundCheck({
237
+ cliPath = process.argv[1],
238
+ statePath = DEFAULT_STATE_PATH,
239
+ throttleMs = DEFAULT_THROTTLE_MS,
240
+ now = Date.now,
241
+ spawnFn = spawn,
242
+ execPath = process.execPath,
243
+ env = process.env,
244
+ } = {}) {
245
+ if (shouldSkipForEnvironment({ env })) return 'skipped';
246
+ const state = readState(statePath);
247
+ if (state && typeof state.lastCheckedAt === 'number' && now() - state.lastCheckedAt < throttleMs) {
248
+ return 'fresh';
249
+ }
250
+ try {
251
+ const child = spawnFn(execPath, [cliPath, INTERNAL_FLAG], {
252
+ detached: true,
253
+ stdio: 'ignore',
254
+ env,
255
+ });
256
+ child.on('error', () => {});
257
+ if (typeof child.unref === 'function') child.unref();
258
+ return 'spawned';
259
+ } catch {
260
+ return 'skipped';
261
+ }
262
+ }
263
+
264
+ export function getQueuedNotice({
265
+ currentVersion,
266
+ statePath = DEFAULT_STATE_PATH,
267
+ env = process.env,
268
+ } = {}) {
269
+ if (shouldSkipForEnvironment({ env })) return null;
270
+ const state = readState(statePath);
271
+ if (!state || typeof state.lastKnownLatest !== 'string') return null;
272
+ if (compareSemver(state.lastKnownLatest, currentVersion) <= 0) return null;
273
+ const { installCmd } = detectPackageManager(env);
274
+ return formatUpdateNotice({
275
+ currentVersion,
276
+ latestVersion: state.lastKnownLatest,
277
+ installCmd,
278
+ });
279
+ }
280
+
281
+ export function runSelfUpdate({
282
+ env = process.env,
283
+ statePath = DEFAULT_STATE_PATH,
284
+ spawnSyncFn = spawnSync,
285
+ stdio = 'inherit',
286
+ } = {}) {
287
+ const { manager, installCmd } = detectPackageManager(env);
288
+ const [command, ...args] = installCmd.split(' ');
289
+ console.error(`Running: ${installCmd}`);
290
+ const result = spawnSyncFn(command, args, { stdio, env });
291
+ if (result.error) {
292
+ console.error(`self-update failed: ${result.error.message}`);
293
+ if (manager === 'npm' && /EACCES|EPERM/i.test(result.error.message)) {
294
+ console.error('Hint: re-run with sudo, or install with a Node version manager (nvm/fnm/volta).');
295
+ }
296
+ return 1;
297
+ }
298
+ if (typeof result.status === 'number' && result.status !== 0) {
299
+ return result.status;
300
+ }
301
+ clearState(statePath);
302
+ return 0;
303
+ }
package/version.mjs CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = '3.1.1';
1
+ export const PACKAGE_VERSION = '3.3.0';