@danhachuel/thunderbolt 0.3.94 → 0.3.96

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/app/main.py CHANGED
@@ -22,7 +22,7 @@ try:
22
22
  except (OSError, json.JSONDecodeError):
23
23
  APP_VERSION = ""
24
24
 
25
- from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, delete_task, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
25
+ from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, delete_task, pipeline_summary, retry_task_with_current_settings, set_channel_defaults, transition_task, update_channel, update_channel_video
26
26
  from hermes_ui.drafts import list_drafts, save_draft
27
27
  from hermes_ui.automation_worker import load_worker_status
28
28
  from hermes_ui.pipeline_worker import load_pipeline_worker_status, recover_stale_tasks, STALE_TASK_SECONDS, WORKER_HEARTBEAT_TIMEOUT_SECONDS
@@ -41,7 +41,7 @@ from hermes_ui.mcp_server import server_status, start_server, stop_server
41
41
  from hermes_ui.material_sources import apply_material_source_cards_to_settings, ensure_material_source_cards, material_source_catalog, material_source_definition, new_material_card, normalize_material_card, selected_material_source
42
42
  from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_PROVIDER_CATALOG, apply_llm_cards_to_settings, ensure_llm_provider_cards, new_llm_card, normalize_llm_card, provider_definition, test_llm_provider_card, stamp_test_result
43
43
  from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, MEDIA_CARDS_KEY, MEDIA_IMAGE_ACTIVE_CARD_KEY, MEDIA_VIDEO_ACTIVE_CARD_KEY, apply_media_provider_cards_to_settings, ensure_media_provider_cards, media_cards_for_pool, media_provider_catalog, media_provider_definition, new_media_card, normalize_media_card
44
- from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file, store_voiceover_file
44
+ from hermes_ui.music import create_music_task, list_music_files, list_music_tasks, materialize_suno_audio, request_suno_generation, run_music_task, store_music_file, store_voiceover_file, transition_music_task
45
45
  from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
46
46
  from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
47
47
  from hermes_ui.influencers import BACKEND_OPTIONS, DOCUMENT_EXTENSIONS, IMAGE_EXTENSIONS, backend_name, backend_status, get_repository, test_backend
@@ -2603,8 +2603,27 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2603
2603
 
2604
2604
 
2605
2605
  def render_music_creation():
2606
- """Expose the complete video-creation UI under the music-oriented navigation entry without changing the original page."""
2607
- render_new_video(page_title="Criação de Músicas", prefix="new_music")
2606
+ """Create audio-only items for the independent Suno/Lyria Music Backlog."""
2607
+ st.title("Criação de Músicas")
2608
+ st.caption("Crie apenas áudio. Os pedidos desta página não criam vídeo, não usam o MoneyPrinterTurbo e não entram no Backlog Vídeos.")
2609
+ settings = read_json("settings.json", {})
2610
+ with st.form("create_music_task_form"):
2611
+ provider_label = st.selectbox("Provider de geração musical", ["Suno AI", "Google Lyria"], key="music_task_provider")
2612
+ title = st.text_input("Título da música", key="music_task_title")
2613
+ prompt = st.text_area("Prompt musical", placeholder="Instrumental cinematográfico, calmo, sem voz...", key="music_task_prompt", height=150)
2614
+ lyria_model = ""
2615
+ if provider_label == "Google Lyria":
2616
+ models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
2617
+ configured = str(settings.get("lyria_model") or models[0])
2618
+ lyria_model = st.selectbox("Modelo Lyria", models, index=models.index(configured) if configured in models else 0, key="music_task_lyria_model")
2619
+ submitted = st.form_submit_button("Adicionar ao Music Backlog", type="primary", use_container_width=True)
2620
+ if submitted:
2621
+ try:
2622
+ task = create_music_task(provider_label, prompt, title, lyria_model)
2623
+ st.success(f"Música adicionada ao Music Backlog: {task['title']}")
2624
+ st.rerun()
2625
+ except ValueError as exc:
2626
+ st.error(str(exc))
2608
2627
 
2609
2628
 
2610
2629
  def render_scripts():
@@ -3637,7 +3656,7 @@ def render_videos():
3637
3656
  st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
3638
3657
  st.caption(f"Os vídeos são guardados em `{STORAGE / 'videos'}`.")
3639
3658
  _render_pipeline_progress_panel()
3640
- tasks = load_standard_video_tasks_for_catalog()
3659
+ tasks = load_video_tasks_for_catalog()
3641
3660
  if not tasks:
3642
3661
  st.info("Nenhum vídeo criado.")
3643
3662
  return
@@ -3697,12 +3716,14 @@ def render_videos():
3697
3716
 
3698
3717
 
3699
3718
  def render_music_backlog() -> None:
3700
- """Render the music-only counterpart of Backlog Videos with the same controls."""
3719
+ """Render the independent audio-only queue; it never reads the video pipeline."""
3701
3720
  st.subheader("Music Backlog")
3702
- st.caption("Acompanhamento das músicas criadas, estados da pipeline e controlos de execução.")
3721
+ st.caption("Fila independente de geração de áudio por Suno AI ou Google Lyria. Não inclui tarefas, worker ou progresso de vídeo.")
3703
3722
  st.caption(f"As músicas são guardadas em `{STORAGE / 'music'}`.")
3704
- _render_pipeline_progress_panel()
3705
- tasks = load_music_tasks_for_catalog()
3723
+ tasks = list_music_tasks()
3724
+ active = [task for task in tasks if str(task.get("state") or "") == "doing"]
3725
+ if active:
3726
+ st.info(f"Geração musical em execução · {len(active)} tarefa(s) de áudio.")
3706
3727
  if not tasks:
3707
3728
  st.info("Nenhuma música criada.")
3708
3729
  return
@@ -3715,10 +3736,10 @@ def render_music_backlog() -> None:
3715
3736
  with st.container(border=True):
3716
3737
  cols = st.columns([2.2, 1, 1, 1.2, 1.8])
3717
3738
  with cols[0]:
3718
- st.write(f"**{task.get('title') or task.get('topic', 'Sem título')}**")
3719
- st.caption(f"Música: {task.get('music_source') or 'rota musical'}")
3720
- st.caption(f"{task.get('channel_name')} · {task.get('id')}")
3721
- music_path = str(task.get("music_path") or (task.get("artifacts") or {}).get("music") or "").strip()
3739
+ st.write(f"**{task.get('title') or 'Música sem título'}**")
3740
+ st.caption(f"Provider: {'Google Lyria' if str(task.get('provider') or '').casefold() == 'lyria' else 'Suno AI'}")
3741
+ st.caption(str(task.get('id') or ''))
3742
+ music_path = str(task.get("audio_path") or "").strip()
3722
3743
  if music_path and Path(music_path).is_file():
3723
3744
  music_file = Path(music_path)
3724
3745
  st.success("Música pronta; pode continuar para o destino configurado.")
@@ -3735,22 +3756,29 @@ def render_music_backlog() -> None:
3735
3756
  else:
3736
3757
  st.caption("A música será disponibilizada quando a etapa de geração terminar.")
3737
3758
  with cols[1]:
3738
- st.caption("Formato")
3739
- st.write(_video_task_format(task))
3759
+ st.caption("Tipo")
3760
+ st.write("Áudio")
3740
3761
  with cols[2]:
3741
- st.write(_pipeline_stage_label(task))
3762
+ st.write({"music_generation": "Geração musical", "completed": "Concluída", "failed": "Falha"}.get(str(task.get("stage") or ""), "Na fila"))
3742
3763
  with cols[3]:
3743
- _render_video_task_state(task)
3764
+ state = str(task.get("state") or "unknown").strip().lower()
3765
+ progress = _video_task_progress(task)
3766
+ st.caption("Estado")
3767
+ st.write(state or "—")
3768
+ st.caption(VIDEO_TASK_STATE_LABELS.get(state, state.replace("_", " ").capitalize() or "Desconhecido"))
3769
+ st.progress(progress, text=f"{progress}%")
3770
+ if task.get("error"):
3771
+ st.error(str(task.get("error") or "")[:500])
3744
3772
  with cols[4]:
3745
3773
  state = str(task.get("state") or "")
3746
3774
  start_col, stop_col = st.columns(2)
3747
3775
  with start_col:
3748
3776
  if st.button("Start", key=f"music_backlog_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
3749
- transition_task(task["id"], "doing")
3777
+ run_music_task(str(task["id"]), read_json("settings.json", {}))
3750
3778
  st.rerun()
3751
3779
  with stop_col:
3752
3780
  if st.button("Stop", key=f"music_backlog_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
3753
- transition_task(task["id"], "blocked")
3781
+ transition_music_task(str(task["id"]), "blocked")
3754
3782
  st.rerun()
3755
3783
 
3756
3784
 
@@ -4026,7 +4054,7 @@ def render_automation():
4026
4054
 
4027
4055
  st.divider()
4028
4056
  st.subheader("Vídeos cadastrados")
4029
- st.caption("Start retoma as etapas já concluídas e só gera novamente o que ainda não estiver pronto. Apagar remove o card da fila após confirmação e preserva os artefactos locais.")
4057
+ st.caption("Start retoma as etapas já concluídas e só gera novamente o que ainda não estiver pronto. Em tarefas falhadas ou bloqueadas, a nova tentativa lê as chaves, prioridades e configurações actualmente guardadas. Apagar remove o card da fila após confirmação e preserva os artefactos locais.")
4030
4058
  tasks = load_video_tasks_for_catalog()
4031
4059
  if not tasks:
4032
4060
  st.info("Ainda não existem vídeos cadastrados.")
@@ -4046,7 +4074,10 @@ def render_automation():
4046
4074
  start_col, stop_col, delete_col = st.columns(3)
4047
4075
  with start_col:
4048
4076
  if st.button("Start", key=f"automation_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
4049
- transition_task(task["id"], "doing")
4077
+ if state in {"failed", "blocked"}:
4078
+ retry_task_with_current_settings(task["id"])
4079
+ else:
4080
+ transition_task(task["id"], "doing")
4050
4081
  st.rerun()
4051
4082
  with stop_col:
4052
4083
  if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
@@ -6102,6 +6133,15 @@ def render_settings():
6102
6133
  widget_key="api_test_voice_suno",
6103
6134
  )
6104
6135
 
6136
+ with st.container(border=True):
6137
+ st.markdown("#### Google Lyria — geração musical")
6138
+ st.caption("Google Lyria gera apenas áudio através da API Gemini Interactions; não usa a pipeline de vídeo.")
6139
+ lyria_api_key = text_setting("Google Lyria API key", "lyria_api_key", secret=True, help_text="Chave da Gemini API com acesso ao modelo Lyria. O valor é guardado apenas localmente.")
6140
+ _render_credential_status(lyria_api_key)
6141
+ lyria_models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
6142
+ saved_lyria_model = str(settings.get("lyria_model") or lyria_models[0])
6143
+ lyria_model = st.selectbox("Modelo Google Lyria", lyria_models, index=lyria_models.index(saved_lyria_model) if saved_lyria_model in lyria_models else 0, key="settings_lyria_model")
6144
+
6105
6145
  with st.expander("Publicação através do Upload-Post", expanded=False):
6106
6146
  upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
6107
6147
  upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
@@ -6150,6 +6190,7 @@ def render_settings():
6150
6190
  "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
6151
6191
  "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
6152
6192
  "sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint,
6193
+ "lyria_api_key": lyria_api_key, "lyria_model": lyria_model,
6153
6194
  "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
6154
6195
  "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
6155
6196
  "upload_post_auto_upload": upload_post_auto_upload,
@@ -328,6 +328,37 @@ def transition_task(task_id: str, state: str | None = None, stage: str | None =
328
328
  return None
329
329
 
330
330
 
331
+ def retry_task_with_current_settings(task_id: str) -> dict[str, Any] | None:
332
+ """Queue a failed or blocked task without persisting API credentials or provider snapshots.
333
+
334
+ The pipeline reloads settings.json immediately before every execution, so a
335
+ retry always uses the currently saved API keys, active provider priorities,
336
+ and provider endpoints while retaining the task's completed artefacts.
337
+ """
338
+ tasks = read_json("tasks.json", [])
339
+ for task in tasks:
340
+ if task.get("id") != task_id:
341
+ continue
342
+ previous_state = str(task.get("state") or "")
343
+ if previous_state not in {"failed", "blocked"}:
344
+ raise ValueError("Apenas tarefas falhadas ou bloqueadas podem ser retomadas.")
345
+ try:
346
+ retry_count = int(task.get("retry_count") or 0)
347
+ except (TypeError, ValueError):
348
+ retry_count = 0
349
+ task["state"] = "to_do"
350
+ task["error"] = None
351
+ task["retry_count"] = retry_count + 1
352
+ task["retry_requested_at"] = now()
353
+ task["retry_config_source"] = "settings.json_at_execution"
354
+ for field in ("failure_api", "failure_provider", "failure_service", "failure_config_fields"):
355
+ task.pop(field, None)
356
+ task["updated_at"] = now()
357
+ write_json("tasks.json", tasks)
358
+ return task
359
+ return None
360
+
361
+
331
362
  def pipeline_summary() -> dict[str, Any]:
332
363
  tasks = read_json("tasks.json", [])
333
364
  channels = read_json("channels.json", [])
@@ -2,6 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import re
5
+ import base64
6
+ import uuid
7
+ from datetime import datetime, timezone
5
8
  from pathlib import Path
6
9
  from typing import Any
7
10
 
@@ -115,3 +118,137 @@ def request_suno_generation(settings: dict[str, Any], prompt: str, title: str =
115
118
  return {"ok": True, "message": "Pedido de música enviado ao endpoint Suno configurado.", "data": body if isinstance(body, dict) else {"response": body}}
116
119
  except (requests.RequestException, ValueError) as exc:
117
120
  return {"ok": False, "message": f"Não foi possível contactar o endpoint Suno configurado: {exc}", "data": {}}
121
+
122
+
123
+ def list_music_tasks() -> list[dict[str, Any]]:
124
+ """Return only tasks created for the independent audio-generation queue."""
125
+ records = storage.read_json("music_tasks.json", [])
126
+ if not isinstance(records, list):
127
+ return []
128
+ return [dict(record) for record in records if isinstance(record, dict) and str(record.get("id") or "").strip()]
129
+
130
+
131
+ def _save_music_tasks(tasks: list[dict[str, Any]]) -> None:
132
+ storage.write_json("music_tasks.json", tasks)
133
+
134
+
135
+ def create_music_task(provider: str, prompt: str, title: str, model: str = "") -> dict[str, Any]:
136
+ """Enqueue one audio-only generation; no video task or video worker is used."""
137
+ cleaned_prompt = str(prompt or "").strip()
138
+ if not cleaned_prompt:
139
+ raise ValueError("Escreva um prompt musical antes de adicionar à fila.")
140
+ now = datetime.now(timezone.utc).isoformat(timespec="seconds")
141
+ task = {
142
+ "id": f"music_{uuid.uuid4().hex[:12]}",
143
+ "kind": "audio_generation",
144
+ "provider": "lyria" if str(provider).strip().casefold() == "google lyria" else "suno",
145
+ "model": str(model or "").strip(),
146
+ "title": str(title or "Música sem título").strip() or "Música sem título",
147
+ "prompt": cleaned_prompt,
148
+ "state": "to_do",
149
+ "stage": "music_generation",
150
+ "progress": 0,
151
+ "audio_path": "",
152
+ "error": "",
153
+ "created_at": now,
154
+ "updated_at": now,
155
+ }
156
+ tasks = list_music_tasks()
157
+ tasks.insert(0, task)
158
+ _save_music_tasks(tasks)
159
+ return task
160
+
161
+
162
+ def _update_music_task(task_id: str, **changes: Any) -> dict[str, Any] | None:
163
+ tasks = list_music_tasks()
164
+ updated: dict[str, Any] | None = None
165
+ for task in tasks:
166
+ if str(task.get("id") or "") == str(task_id):
167
+ task.update(changes)
168
+ task["updated_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
169
+ updated = dict(task)
170
+ break
171
+ if updated is not None:
172
+ _save_music_tasks(tasks)
173
+ return updated
174
+
175
+
176
+ def transition_music_task(task_id: str, state: str) -> dict[str, Any] | None:
177
+ """Change only an independent audio queue state."""
178
+ if state not in {"to_do", "doing", "blocked", "done", "failed", "cancelled"}:
179
+ raise ValueError("Estado de música inválido.")
180
+ return _update_music_task(task_id, state=state)
181
+
182
+
183
+ def _extract_lyria_audio(payload: dict[str, Any]) -> bytes:
184
+ """Extract a base64 audio block from Gemini Interactions without logging the payload."""
185
+ candidates: list[Any] = []
186
+ output_audio = payload.get("output_audio") if isinstance(payload, dict) else None
187
+ if isinstance(output_audio, dict):
188
+ candidates.append(output_audio.get("data"))
189
+ for step in payload.get("steps", []) if isinstance(payload, dict) else []:
190
+ if not isinstance(step, dict):
191
+ continue
192
+ content = step.get("content")
193
+ for block in content if isinstance(content, list) else []:
194
+ if isinstance(block, dict) and str(block.get("type") or "").casefold() == "audio":
195
+ candidates.append(block.get("data"))
196
+ for candidate in candidates:
197
+ if isinstance(candidate, str) and candidate.strip():
198
+ try:
199
+ decoded = base64.b64decode(candidate, validate=True)
200
+ except (ValueError, TypeError):
201
+ continue
202
+ if decoded:
203
+ return decoded
204
+ return b""
205
+
206
+
207
+ def request_lyria_generation(settings: dict[str, Any], prompt: str, title: str = "", model: str = "") -> dict[str, Any]:
208
+ """Generate audio through Google Lyria's Interactions API without invoking video tooling."""
209
+ api_key = str(settings.get("lyria_api_key") or settings.get("gemini_api_key") or "").strip()
210
+ selected_model = str(model or settings.get("lyria_model") or "lyria-3-clip-preview").strip()
211
+ if not api_key:
212
+ return {"ok": False, "message": "Configure a Google Lyria API key em Configurações antes de solicitar uma música.", "data": {}}
213
+ try:
214
+ response = requests.post(
215
+ "https://generativelanguage.googleapis.com/v1beta/interactions",
216
+ headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
217
+ json={"model": selected_model, "input": str(prompt or "").strip()},
218
+ timeout=180,
219
+ )
220
+ if response.status_code >= 400:
221
+ return {"ok": False, "message": f"Google Lyria devolveu HTTP {response.status_code}.", "data": {"status_code": response.status_code}}
222
+ body = response.json() if response.content else {}
223
+ audio = _extract_lyria_audio(body if isinstance(body, dict) else {})
224
+ if not audio:
225
+ return {"ok": False, "message": "Google Lyria não devolveu áudio na resposta.", "data": {}}
226
+ output = store_music_file(f"{title or 'lyria-generated'}.mp3", audio)
227
+ return {"ok": True, "message": "Música gerada por Google Lyria.", "data": {"audio_path": str(output), "model": selected_model}}
228
+ except (requests.RequestException, ValueError):
229
+ return {"ok": False, "message": "Não foi possível contactar Google Lyria.", "data": {}}
230
+
231
+
232
+ def run_music_task(task_id: str, settings: dict[str, Any]) -> dict[str, Any] | None:
233
+ """Run exactly one queued audio generation and persist only its audio artefact."""
234
+ task = next((record for record in list_music_tasks() if str(record.get("id") or "") == str(task_id)), None)
235
+ if not task:
236
+ return None
237
+ _update_music_task(task_id, state="doing", stage="music_generation", progress=15, error="")
238
+ provider = str(task.get("provider") or "suno").casefold()
239
+ if provider == "lyria":
240
+ result = request_lyria_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""), str(task.get("model") or ""))
241
+ audio_path = str((result.get("data") or {}).get("audio_path") or "")
242
+ else:
243
+ result = request_suno_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""))
244
+ try:
245
+ output = materialize_suno_audio(result.get("data") or {}, str(task.get("title") or "suno-generated.mp3")) if result.get("ok") else None
246
+ audio_path = str(output or "")
247
+ except (OSError, requests.RequestException, ValueError):
248
+ audio_path = ""
249
+ result = {"ok": False, "message": "A música Suno foi solicitada, mas não foi possível guardar o áudio devolvido.", "data": {}}
250
+ if result.get("ok") and audio_path:
251
+ completed = _update_music_task(task_id, state="done", stage="completed", progress=100, audio_path=audio_path, error="")
252
+ record_notification("music_completed", f"Música concluída: {task.get('title') or 'Música'}", "Áudio guardado no Music Backlog.", metadata={"task_id": task_id, "provider": provider, "filename": Path(audio_path).name}, dedupe_key=f"music-task:{task_id}:{audio_path}")
253
+ return completed
254
+ return _update_music_task(task_id, state="failed", stage="failed", progress=100, error=str(result.get("message") or "A geração musical falhou.")[:500])
@@ -24,6 +24,7 @@ DEFAULTS: dict[str, Any] = {
24
24
  "channels.json": [],
25
25
  "channel_videos.json": [],
26
26
  "tasks.json": [],
27
+ "music_tasks.json": [],
27
28
  "queues.json": {"niche": [], "blueprint": [], "brand": [], "script": [], "title": [], "thumbnail": [], "video": [], "edit": [], "upload": []},
28
29
  "batches.json": [],
29
30
  "uploads.json": [],
@@ -300,6 +301,8 @@ DEFAULTS: dict[str, Any] = {
300
301
  "suno_api_key": "",
301
302
  "suno_api_base_url": "",
302
303
  "suno_api_endpoint": "/api/generate",
304
+ "lyria_api_key": "",
305
+ "lyria_model": "lyria-3-clip-preview",
303
306
  "jewelmusic_enabled": False,
304
307
  "jewelmusic_api_key": "",
305
308
  "jewelmusic_base_url": "https://api.jewelmusic.com",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.94",
3
+ "version": "0.3.96",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",