@danhachuel/thunderbolt 0.2.49 → 0.2.50
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/MANUAL-INSTALACAO.md +27 -18
- package/README.md +15 -5
- package/app/main.py +323 -28
- package/hermes_ui/automation_worker.py +49 -10
- package/hermes_ui/creative_generation.py +278 -0
- package/hermes_ui/domain.py +50 -21
- package/package.json +2 -1
- package/seed/references/ai-tells.md +336 -0
- package/seed/references/humanize-integration.md +147 -0
- package/seed/references/thumbnail-checklist.md +178 -0
- package/seed/references/title-formulas.md +288 -0
- package/seed/references/trend-intelligence.md +99 -0
- package/seed/references/viral-thumbnails.md +470 -0
- package/seed/references/viral-titles.md +440 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
REFERENCES_DIR = ROOT / "seed" / "references"
|
|
14
|
+
|
|
15
|
+
REFERENCE_FILES = {
|
|
16
|
+
"title_formulas": "title-formulas.md",
|
|
17
|
+
"viral_titles": "viral-titles.md",
|
|
18
|
+
"thumbnail_checklist": "thumbnail-checklist.md",
|
|
19
|
+
"viral_thumbnails": "viral-thumbnails.md",
|
|
20
|
+
"trend_intelligence": "trend-intelligence.md",
|
|
21
|
+
"humanize": "humanize-integration.md",
|
|
22
|
+
"ai_tells": "ai-tells.md",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CreativeGenerationError(RuntimeError):
|
|
27
|
+
"""Raised when a configured LLM cannot produce a valid creative package."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _provider_config(settings: dict[str, Any]) -> tuple[str, str, str, str]:
|
|
31
|
+
provider = str(settings.get("llm_provider") or "openai").strip().lower()
|
|
32
|
+
key = str(settings.get(f"{provider}_api_key") or "").strip()
|
|
33
|
+
base_url = str(settings.get(f"{provider}_base_url") or "").strip()
|
|
34
|
+
model = str(settings.get(f"{provider}_model_name") or "").strip()
|
|
35
|
+
if provider == "openai":
|
|
36
|
+
base_url = base_url or "https://api.openai.com/v1"
|
|
37
|
+
elif provider == "ollama":
|
|
38
|
+
base_url = base_url or "http://127.0.0.1:11434/v1"
|
|
39
|
+
if not base_url:
|
|
40
|
+
raise CreativeGenerationError(
|
|
41
|
+
f"O provider LLM '{provider}' não tem Base URL configurada em Configurações Técnicas > API Keys."
|
|
42
|
+
)
|
|
43
|
+
if not model:
|
|
44
|
+
raise CreativeGenerationError(
|
|
45
|
+
f"O provider LLM '{provider}' não tem modelo configurado em Configurações Técnicas > API Keys."
|
|
46
|
+
)
|
|
47
|
+
if provider not in {"ollama", "litellm"} and not key:
|
|
48
|
+
raise CreativeGenerationError(
|
|
49
|
+
f"Configure a API key do provider LLM '{provider}' em Configurações Técnicas > API Keys."
|
|
50
|
+
)
|
|
51
|
+
return provider, key, base_url.rstrip("/"), model
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@lru_cache(maxsize=1)
|
|
55
|
+
def reference_bundle() -> str:
|
|
56
|
+
sections: list[str] = []
|
|
57
|
+
for label, filename in REFERENCE_FILES.items():
|
|
58
|
+
path = REFERENCES_DIR / filename
|
|
59
|
+
try:
|
|
60
|
+
content = path.read_text(encoding="utf-8")
|
|
61
|
+
except OSError:
|
|
62
|
+
continue
|
|
63
|
+
# Keep prompts bounded while preserving the concrete rules from the attachments.
|
|
64
|
+
sections.append(f"[{label}]\n{content[:4200]}")
|
|
65
|
+
return "\n\n".join(sections)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _json_content(payload: dict[str, Any]) -> dict[str, Any]:
|
|
69
|
+
choices = payload.get("choices") or []
|
|
70
|
+
if not choices:
|
|
71
|
+
raise CreativeGenerationError("O endpoint LLM devolveu uma resposta sem choices.")
|
|
72
|
+
message = (choices[0] or {}).get("message") or {}
|
|
73
|
+
content = message.get("content")
|
|
74
|
+
if isinstance(content, list):
|
|
75
|
+
content = "".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
|
|
76
|
+
text = str(content or "").strip()
|
|
77
|
+
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
|
|
78
|
+
text = re.sub(r"\s*```$", "", text).strip()
|
|
79
|
+
try:
|
|
80
|
+
parsed = json.loads(text)
|
|
81
|
+
except json.JSONDecodeError as exc:
|
|
82
|
+
raise CreativeGenerationError("O endpoint LLM não devolveu JSON válido.") from exc
|
|
83
|
+
if not isinstance(parsed, dict):
|
|
84
|
+
raise CreativeGenerationError("A resposta do endpoint LLM não é um objecto JSON.")
|
|
85
|
+
return parsed
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _chat_json(settings: dict[str, Any], system_prompt: str, user_prompt: str) -> dict[str, Any]:
|
|
89
|
+
_provider, api_key, base_url, model = _provider_config(settings)
|
|
90
|
+
headers = {"Content-Type": "application/json"}
|
|
91
|
+
if api_key:
|
|
92
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
93
|
+
body = {
|
|
94
|
+
"model": model,
|
|
95
|
+
"messages": [
|
|
96
|
+
{"role": "system", "content": system_prompt},
|
|
97
|
+
{"role": "user", "content": user_prompt},
|
|
98
|
+
],
|
|
99
|
+
"response_format": {"type": "json_object"},
|
|
100
|
+
}
|
|
101
|
+
endpoint = f"{base_url}/chat/completions"
|
|
102
|
+
try:
|
|
103
|
+
response = requests.post(endpoint, headers=headers, json=body, timeout=120)
|
|
104
|
+
except requests.RequestException as exc:
|
|
105
|
+
raise CreativeGenerationError(f"Não foi possível contactar o provider LLM: {exc}") from exc
|
|
106
|
+
if response.status_code >= 400:
|
|
107
|
+
detail = response.text[:500].replace(api_key, "[REDACTED]") if api_key else response.text[:500]
|
|
108
|
+
raise CreativeGenerationError(f"O provider LLM devolveu HTTP {response.status_code}: {detail}")
|
|
109
|
+
try:
|
|
110
|
+
payload = response.json()
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
raise CreativeGenerationError("O provider LLM devolveu uma resposta que não é JSON.") from exc
|
|
113
|
+
return _json_content(payload)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def channel_context(channel: dict[str, Any], blueprint: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
117
|
+
blueprint = blueprint or {}
|
|
118
|
+
metadata = blueprint.get("metadata") if isinstance(blueprint.get("metadata"), dict) else {}
|
|
119
|
+
return {
|
|
120
|
+
"channel_name": str(channel.get("name") or "Canal sem nome"),
|
|
121
|
+
"handle": str(channel.get("handle") or ""),
|
|
122
|
+
"description": str(channel.get("description") or ""),
|
|
123
|
+
"language": str(channel.get("language") or "Português"),
|
|
124
|
+
"style_wide": str(channel.get("style_wide") or "pexels"),
|
|
125
|
+
"blueprint_id": str(blueprint.get("id") or channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
|
|
126
|
+
"blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
|
|
127
|
+
"blueprint_niche": str(blueprint.get("target_niche") or blueprint.get("niche") or metadata.get("target_niche") or metadata.get("niche") or ""),
|
|
128
|
+
"default_voice": str(channel.get("default_voice") or channel.get("voice") or ""),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def generate_topic_for_channel(
|
|
133
|
+
settings: dict[str, Any],
|
|
134
|
+
channel: dict[str, Any],
|
|
135
|
+
blueprint: dict[str, Any] | None = None,
|
|
136
|
+
user_context: str = "",
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
context = channel_context(channel, blueprint)
|
|
139
|
+
system = (
|
|
140
|
+
"És o estratega editorial de um canal faceless. Gera um briefing específico para este canal. "
|
|
141
|
+
"Não inventes factos sobre o canal. Se o contexto já tiver um tópico, desenvolve-o sem trocar o nicho. "
|
|
142
|
+
"Escreve de forma natural, sem introduções artificiais, frases de IA, clickbait enganoso ou CTA genérico. "
|
|
143
|
+
"Responde apenas com JSON válido com as chaves topic, angle, hook, niche e rationale."
|
|
144
|
+
)
|
|
145
|
+
user = json.dumps(
|
|
146
|
+
{
|
|
147
|
+
"channel": context,
|
|
148
|
+
"user_context": user_context.strip(),
|
|
149
|
+
"reference_rules": reference_bundle(),
|
|
150
|
+
"requirements": [
|
|
151
|
+
"topic em uma frase clara",
|
|
152
|
+
"angle contrarian ou inesperado quando fizer sentido",
|
|
153
|
+
"hook para os primeiros segundos",
|
|
154
|
+
"niche coerente com o Blueprint e descrição",
|
|
155
|
+
"rationale curta e prática",
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
ensure_ascii=False,
|
|
159
|
+
)
|
|
160
|
+
result = _chat_json(settings, system, user)
|
|
161
|
+
required = ("topic", "angle", "hook", "niche", "rationale")
|
|
162
|
+
if any(not str(result.get(key) or "").strip() for key in required):
|
|
163
|
+
raise CreativeGenerationError("O briefing gerado veio incompleto; tente novamente.")
|
|
164
|
+
result["topic_source"] = "llm"
|
|
165
|
+
result["channel_id"] = str(channel.get("id") or "")
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _score(value: Any) -> int:
|
|
170
|
+
try:
|
|
171
|
+
return max(0, min(3, int(value)))
|
|
172
|
+
except (TypeError, ValueError):
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _short_overlay(value: Any) -> str:
|
|
177
|
+
words = re.findall(r"\S+", str(value or "").strip())
|
|
178
|
+
return " ".join(words[:4])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def generate_creative_package(
|
|
182
|
+
settings: dict[str, Any],
|
|
183
|
+
channel: dict[str, Any],
|
|
184
|
+
topic: str,
|
|
185
|
+
blueprint: dict[str, Any] | None = None,
|
|
186
|
+
language: str = "",
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
if not topic.strip():
|
|
189
|
+
raise CreativeGenerationError("É necessário um tópico ou briefing antes de gerar título e thumbnail.")
|
|
190
|
+
context = channel_context(channel, blueprint)
|
|
191
|
+
system = (
|
|
192
|
+
"És director editorial e de thumbnails para YouTube. Cria um pacote coerente de título e thumbnail "
|
|
193
|
+
"para o tópico fornecido. Gera exactamente pelo menos 20 títulos candidatos e entre 3 e 5 variantes de thumbnail. "
|
|
194
|
+
"O título deve carregar keywords no início, ter curiosidade, especificidade e emoção, sem clickbait falso. "
|
|
195
|
+
"A thumbnail deve ter no máximo três elementos, alto contraste, uma composição clara, texto opcional de até 4 palavras, "
|
|
196
|
+
"safe zones e leitura em 120px. O texto da thumbnail não pode repetir o título integralmente. Remove AI tells. "
|
|
197
|
+
"Responde apenas com JSON válido nas chaves selected_title, title_candidates e thumbnail_variants."
|
|
198
|
+
)
|
|
199
|
+
user = json.dumps(
|
|
200
|
+
{
|
|
201
|
+
"channel": context,
|
|
202
|
+
"language": language or context["language"],
|
|
203
|
+
"topic": topic.strip(),
|
|
204
|
+
"reference_rules": reference_bundle(),
|
|
205
|
+
"title_candidates_schema": {
|
|
206
|
+
"title": "string",
|
|
207
|
+
"formula": "string",
|
|
208
|
+
"curiosity_score": "integer 0-3",
|
|
209
|
+
"specificity_score": "integer 0-3",
|
|
210
|
+
"emotional_score": "integer 0-3",
|
|
211
|
+
},
|
|
212
|
+
"thumbnail_variant_schema": {
|
|
213
|
+
"concept": "string",
|
|
214
|
+
"overlay_text": "string, maximum 4 words",
|
|
215
|
+
"composition": "string",
|
|
216
|
+
"color_palette": "string",
|
|
217
|
+
"subject": "string",
|
|
218
|
+
"image_prompt": "string, no text rendered in the image",
|
|
219
|
+
"title_synergy": "string",
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
ensure_ascii=False,
|
|
223
|
+
)
|
|
224
|
+
result = _chat_json(settings, system, user)
|
|
225
|
+
raw_titles = result.get("title_candidates")
|
|
226
|
+
if not isinstance(raw_titles, list) or len(raw_titles) < 20:
|
|
227
|
+
raise CreativeGenerationError("O provider deve devolver pelo menos 20 títulos candidatos.")
|
|
228
|
+
titles: list[dict[str, Any]] = []
|
|
229
|
+
for item in raw_titles[:30]:
|
|
230
|
+
if not isinstance(item, dict) or not str(item.get("title") or "").strip():
|
|
231
|
+
continue
|
|
232
|
+
title = str(item["title"]).strip()
|
|
233
|
+
titles.append(
|
|
234
|
+
{
|
|
235
|
+
"title": title,
|
|
236
|
+
"formula": str(item.get("formula") or "custom").strip(),
|
|
237
|
+
"curiosity_score": _score(item.get("curiosity_score")),
|
|
238
|
+
"specificity_score": _score(item.get("specificity_score")),
|
|
239
|
+
"emotional_score": _score(item.get("emotional_score")),
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
if len(titles) < 20:
|
|
243
|
+
raise CreativeGenerationError("Os títulos devolvidos pelo provider não têm conteúdo suficiente.")
|
|
244
|
+
variants_raw = result.get("thumbnail_variants")
|
|
245
|
+
if not isinstance(variants_raw, list) or len(variants_raw) < 3:
|
|
246
|
+
raise CreativeGenerationError("O provider deve devolver pelo menos 3 variantes de thumbnail.")
|
|
247
|
+
variants: list[dict[str, Any]] = []
|
|
248
|
+
for item in variants_raw[:5]:
|
|
249
|
+
if not isinstance(item, dict):
|
|
250
|
+
continue
|
|
251
|
+
if not str(item.get("concept") or "").strip() or not str(item.get("image_prompt") or "").strip():
|
|
252
|
+
continue
|
|
253
|
+
variants.append(
|
|
254
|
+
{
|
|
255
|
+
"concept": str(item.get("concept") or "").strip(),
|
|
256
|
+
"overlay_text": _short_overlay(item.get("overlay_text")),
|
|
257
|
+
"composition": str(item.get("composition") or "").strip(),
|
|
258
|
+
"color_palette": str(item.get("color_palette") or "").strip(),
|
|
259
|
+
"subject": str(item.get("subject") or "").strip(),
|
|
260
|
+
"image_prompt": str(item.get("image_prompt") or "").strip(),
|
|
261
|
+
"title_synergy": str(item.get("title_synergy") or "").strip(),
|
|
262
|
+
"status": "prompt_ready",
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
if len(variants) < 3:
|
|
266
|
+
raise CreativeGenerationError("As variantes de thumbnail devolvidas pelo provider estão incompletas.")
|
|
267
|
+
selected_title = str(result.get("selected_title") or titles[0]["title"]).strip()
|
|
268
|
+
if selected_title not in {item["title"] for item in titles}:
|
|
269
|
+
selected_title = titles[0]["title"]
|
|
270
|
+
return {
|
|
271
|
+
"title": selected_title,
|
|
272
|
+
"title_candidates": titles,
|
|
273
|
+
"thumbnail_variant": variants[0],
|
|
274
|
+
"thumbnail_variants": variants,
|
|
275
|
+
"thumbnail_status": "prompt_ready",
|
|
276
|
+
"topic_source": "llm",
|
|
277
|
+
"generated_by": "configured_llm",
|
|
278
|
+
}
|
package/hermes_ui/domain.py
CHANGED
|
@@ -108,39 +108,66 @@ def create_batch(mode: str, channel_ids: list[str], topic: str, quantity: int, o
|
|
|
108
108
|
|
|
109
109
|
|
|
110
110
|
def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
111
|
+
"""Expand a batch into tasks, allowing independent payloads for each channel."""
|
|
111
112
|
tasks = read_json("tasks.json", [])
|
|
112
113
|
channels = {c["id"]: c for c in read_json("channels.json", [])}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
options = batch.get("options") or {}
|
|
115
|
+
channel_payloads = options.get("channel_payloads") or {}
|
|
116
|
+
target_channels = batch.get("channel_ids") or []
|
|
117
|
+
count = max(1, int(batch.get("quantity") or 1))
|
|
118
|
+
if batch.get("mode") == "general":
|
|
116
119
|
count = 1
|
|
117
120
|
created: list[dict[str, Any]] = []
|
|
118
121
|
for channel_id in target_channels:
|
|
122
|
+
channel = channels.get(channel_id, {})
|
|
123
|
+
payload = channel_payloads.get(channel_id) if isinstance(channel_payloads, dict) else None
|
|
124
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
119
125
|
for index in range(count):
|
|
120
|
-
|
|
126
|
+
default_topic = str(batch.get("topic") or "").strip()
|
|
127
|
+
if count == 1:
|
|
128
|
+
topic = str(payload.get("topic") or default_topic).strip()
|
|
129
|
+
else:
|
|
130
|
+
topic = str(payload.get("topic") or f"{default_topic} — variação {index + 1}").strip()
|
|
131
|
+
if not topic:
|
|
132
|
+
topic = f"Vídeo para {channel.get('name', 'Canal')}"
|
|
133
|
+
title = str(payload.get("title") or topic).strip()
|
|
134
|
+
artifacts = dict(payload.get("artifacts") or {})
|
|
135
|
+
thumbnail_path = str(payload.get("thumbnail_path") or "").strip()
|
|
136
|
+
if thumbnail_path:
|
|
137
|
+
artifacts.setdefault("thumbnail", thumbnail_path)
|
|
121
138
|
task = {
|
|
122
139
|
"id": make_id("video"),
|
|
123
140
|
"batch_id": batch["id"],
|
|
124
|
-
"creation_mode": batch
|
|
141
|
+
"creation_mode": batch.get("mode", "single"),
|
|
125
142
|
"channel_id": channel_id,
|
|
126
143
|
"channel_name": channel.get("name", "Canal"),
|
|
127
|
-
"topic":
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
144
|
+
"topic": topic,
|
|
145
|
+
"title": title,
|
|
146
|
+
"topic_source": str(payload.get("topic_source") or options.get("topic_source") or "manual"),
|
|
147
|
+
"language": payload.get("language", options.get("language", channel.get("language", "Português"))),
|
|
148
|
+
"format": payload.get("format", options.get("format", "wide")),
|
|
149
|
+
"style_wide": payload.get("style_wide", options.get("style_wide", channel.get("style_wide", "pexels"))),
|
|
150
|
+
"style_ia": payload.get("style_ia", options.get("style_ia", "")),
|
|
151
|
+
"music_mode": payload.get("music_mode", options.get("music_mode", False)),
|
|
152
|
+
"music_path": payload.get("music_path", options.get("music_path", "")),
|
|
153
|
+
"music_source": payload.get("music_source", options.get("music_source", "")),
|
|
154
|
+
"background_mode": payload.get("background_mode", options.get("background_mode", "stock")),
|
|
155
|
+
"blueprint_id": payload.get("blueprint_id") or channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
|
|
156
|
+
"blueprint_name": payload.get("blueprint_name", ""),
|
|
157
|
+
"voice": payload.get("voice") or channel.get("default_voice") or channel.get("voice", ""),
|
|
138
158
|
"automation_on": bool(channel.get("automation_on", False)),
|
|
139
159
|
"automation_time": channel.get("automation_time", "00:00"),
|
|
160
|
+
"thumbnail_variant": payload.get("thumbnail_variant", {}),
|
|
161
|
+
"thumbnail_variants": payload.get("thumbnail_variants", []),
|
|
162
|
+
"thumbnail_prompt": payload.get("thumbnail_prompt", ""),
|
|
163
|
+
"thumbnail_text": payload.get("thumbnail_text", ""),
|
|
164
|
+
"thumbnail_status": payload.get("thumbnail_status", "not_generated"),
|
|
165
|
+
"title_candidates": payload.get("title_candidates", []),
|
|
166
|
+
"ai_generation": payload.get("ai_generation", {}),
|
|
140
167
|
"stage": "script",
|
|
141
168
|
"state": "to_do",
|
|
142
169
|
"progress": 0,
|
|
143
|
-
"artifacts":
|
|
170
|
+
"artifacts": artifacts,
|
|
144
171
|
"error": None,
|
|
145
172
|
"created_at": now(),
|
|
146
173
|
"updated_at": now(),
|
|
@@ -148,10 +175,12 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
148
175
|
tasks.append(task)
|
|
149
176
|
created.append(task)
|
|
150
177
|
write_json("tasks.json", tasks)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
queues
|
|
154
|
-
|
|
178
|
+
queues = read_json("queues.json", {})
|
|
179
|
+
if not isinstance(queues, dict):
|
|
180
|
+
queues = {}
|
|
181
|
+
queues.setdefault("script", [])
|
|
182
|
+
queues["script"].extend(task["id"] for task in created)
|
|
183
|
+
write_json("queues.json", queues)
|
|
155
184
|
return created
|
|
156
185
|
|
|
157
186
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danhachuel/thunderbolt",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.50",
|
|
4
4
|
"description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
|
|
5
5
|
"main": "scripts/cli.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"storage/blueprints/**/*.json",
|
|
18
18
|
"seed/blueprints/**/*.json",
|
|
19
19
|
"seed/skills/*.md",
|
|
20
|
+
"seed/references/*.md",
|
|
20
21
|
"README.md",
|
|
21
22
|
"MANUAL-INSTALACAO.md",
|
|
22
23
|
"THIRD-PARTY-NOTICES.md",
|