arkaos 4.30.0 → 4.32.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 +1 -1
- package/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/config/claude-agents/brand-director.md +1 -1
- package/config/claude-agents/content-strategist.md +1 -1
- package/config/claude-agents/frontend-dev.md +1 -1
- package/config/claude-agents/marketing-director.md +1 -1
- package/config/claude-agents/scriptwriter.md +1 -1
- package/config/claude-agents/trends-analyst.md +1 -1
- package/config/claude-agents/video-producer.md +1 -1
- package/config/skills-curated.yaml +2 -1
- package/config/skills-provenance.yaml +6 -0
- package/core/governance/evidence_checks.py +185 -1
- package/core/keys.py +17 -5
- package/departments/brand/agents/brand-director.yaml +1 -0
- package/departments/brand/skills/design-review/SKILL.md +6 -0
- package/departments/brand/skills/ux-audit/SKILL.md +4 -1
- package/departments/content/agents/content-strategist.yaml +1 -0
- package/departments/content/agents/production/trends-analyst.yaml +1 -0
- package/departments/content/agents/production/video-producer.yaml +1 -0
- package/departments/content/agents/scriptwriter.yaml +1 -0
- package/departments/dev/agents/frontend-dev.yaml +1 -0
- package/departments/dev/skills/animated-website/SKILL.md +14 -1
- package/departments/dev/skills/watch/SKILL.md +162 -0
- package/departments/dev/skills/watch/references/claude-video.LICENSE +21 -0
- package/departments/dev/skills/watch/scripts/config.py +145 -0
- package/departments/dev/skills/watch/scripts/download.py +179 -0
- package/departments/dev/skills/watch/scripts/frames.py +762 -0
- package/departments/dev/skills/watch/scripts/setup.py +373 -0
- package/departments/dev/skills/watch/scripts/transcribe.py +95 -0
- package/departments/dev/skills/watch/scripts/watch.py +531 -0
- package/departments/dev/skills/watch/scripts/whisper.py +466 -0
- package/departments/marketing/agents/marketing-director.yaml +1 -0
- package/departments/marketing/skills/ad-creative/SKILL.md +5 -0
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/zed/.rules +1 -1
- package/installer/doctor.js +38 -4
- package/installer/frontend-tooling.js +36 -0
- package/knowledge/agents-registry-v2.json +8 -1
- package/knowledge/skills-manifest.json +14 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Transcribe a video via Groq or OpenAI Whisper API.
|
|
3
|
+
|
|
4
|
+
Strategy: extract audio (mono 16kHz mp3, tiny payload), upload to whichever
|
|
5
|
+
API has a key. Returns segments in the same shape as transcribe.parse_vtt so
|
|
6
|
+
the rest of the pipeline (filter_range, format_transcript) doesn't care where
|
|
7
|
+
the transcript came from.
|
|
8
|
+
|
|
9
|
+
Pure stdlib — no `pip install groq` or `pip install openai` needed.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import mimetypes
|
|
17
|
+
import shutil
|
|
18
|
+
import ssl
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import uuid
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from urllib.request import Request, urlopen
|
|
26
|
+
|
|
27
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
28
|
+
if str(SCRIPT_DIR) not in sys.path:
|
|
29
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
30
|
+
from config import resolve_api_key # noqa: E402
|
|
31
|
+
|
|
32
|
+
GROQ_ENDPOINT = "https://api.groq.com/openai/v1/audio/transcriptions"
|
|
33
|
+
GROQ_MODEL = "whisper-large-v3"
|
|
34
|
+
|
|
35
|
+
OPENAI_ENDPOINT = "https://api.openai.com/v1/audio/transcriptions"
|
|
36
|
+
OPENAI_MODEL = "whisper-1"
|
|
37
|
+
|
|
38
|
+
# Both Groq's free tier and OpenAI whisper-1 cap uploads at 25 MB. We target a
|
|
39
|
+
# margin under that so multipart framing overhead never pushes a chunk over.
|
|
40
|
+
MAX_UPLOAD_BYTES = 24 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def plan_chunks(
|
|
44
|
+
total_seconds: float,
|
|
45
|
+
total_bytes: int,
|
|
46
|
+
max_bytes: int = MAX_UPLOAD_BYTES,
|
|
47
|
+
) -> list[tuple[float, float]]:
|
|
48
|
+
"""Split a duration into contiguous (offset, duration) chunks under max_bytes.
|
|
49
|
+
|
|
50
|
+
Size scales linearly with duration (constant-bitrate mono mp3), so an even
|
|
51
|
+
time split yields evenly-sized chunks. Returns a single full-length chunk
|
|
52
|
+
when the audio already fits.
|
|
53
|
+
"""
|
|
54
|
+
if total_bytes <= max_bytes or total_seconds <= 0:
|
|
55
|
+
return [(0.0, total_seconds)]
|
|
56
|
+
|
|
57
|
+
n = math.ceil(total_bytes / max_bytes)
|
|
58
|
+
chunk = total_seconds / n
|
|
59
|
+
plan: list[tuple[float, float]] = []
|
|
60
|
+
for i in range(n):
|
|
61
|
+
offset = i * chunk
|
|
62
|
+
# The last chunk absorbs any rounding remainder so durations sum exactly.
|
|
63
|
+
duration = (total_seconds - offset) if i == n - 1 else chunk
|
|
64
|
+
plan.append((round(offset, 3), round(duration, 3)))
|
|
65
|
+
return plan
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_api_key(preferred: str | None = None) -> tuple[str, str] | tuple[None, None]:
|
|
69
|
+
"""Return (backend, api_key). Prefers Groq, falls back to OpenAI.
|
|
70
|
+
|
|
71
|
+
If `preferred` is "groq" or "openai", only that backend's key is
|
|
72
|
+
considered. Resolution order lives in config.resolve_api_key:
|
|
73
|
+
env -> ~/.arkaos/keys.json (`/arka keys`) -> ~/.arkaos/watch.env -> ./.env.
|
|
74
|
+
"""
|
|
75
|
+
candidates = (("GROQ_API_KEY", "groq"), ("OPENAI_API_KEY", "openai"))
|
|
76
|
+
if preferred is not None:
|
|
77
|
+
candidates = tuple(c for c in candidates if c[1] == preferred)
|
|
78
|
+
|
|
79
|
+
for key_name, backend in candidates:
|
|
80
|
+
value = resolve_api_key(key_name)
|
|
81
|
+
if value:
|
|
82
|
+
return backend, value
|
|
83
|
+
|
|
84
|
+
return None, None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extract_audio(video_path: str, out_path: Path) -> Path:
|
|
88
|
+
"""Extract mono 16kHz 64kbps mp3 — ~480 kB/min, fits any Whisper limit."""
|
|
89
|
+
if shutil.which("ffmpeg") is None:
|
|
90
|
+
raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
|
|
91
|
+
|
|
92
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
cmd = [
|
|
94
|
+
"ffmpeg",
|
|
95
|
+
"-hide_banner",
|
|
96
|
+
"-loglevel", "error",
|
|
97
|
+
"-y",
|
|
98
|
+
"-i", str(Path(video_path).resolve()),
|
|
99
|
+
"-vn",
|
|
100
|
+
"-acodec", "libmp3lame",
|
|
101
|
+
"-ar", "16000",
|
|
102
|
+
"-ac", "1",
|
|
103
|
+
"-b:a", "64k",
|
|
104
|
+
str(out_path.resolve()),
|
|
105
|
+
]
|
|
106
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
107
|
+
if result.returncode != 0:
|
|
108
|
+
raise SystemExit(f"ffmpeg audio extraction failed: {result.stderr.strip()}")
|
|
109
|
+
if not out_path.exists() or out_path.stat().st_size == 0:
|
|
110
|
+
raise SystemExit("ffmpeg produced no audio — video may have no audio track")
|
|
111
|
+
return out_path
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def audio_duration(audio_path: Path) -> float:
|
|
115
|
+
"""Return the duration of an audio file in seconds via ffprobe."""
|
|
116
|
+
if shutil.which("ffprobe") is None:
|
|
117
|
+
raise SystemExit("ffprobe is not installed. Install with: brew install ffmpeg")
|
|
118
|
+
|
|
119
|
+
result = subprocess.run(
|
|
120
|
+
[
|
|
121
|
+
"ffprobe",
|
|
122
|
+
"-v", "quiet",
|
|
123
|
+
"-print_format", "json",
|
|
124
|
+
"-show_format",
|
|
125
|
+
str(audio_path.resolve()),
|
|
126
|
+
],
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
)
|
|
130
|
+
if result.returncode != 0:
|
|
131
|
+
raise SystemExit(f"ffprobe failed: {result.stderr.strip()}")
|
|
132
|
+
fmt = json.loads(result.stdout or "{}").get("format", {})
|
|
133
|
+
return float(fmt.get("duration") or 0.0)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def split_audio(
|
|
137
|
+
full_audio: Path,
|
|
138
|
+
work_dir: Path,
|
|
139
|
+
plan: list[tuple[float, float]],
|
|
140
|
+
) -> list[tuple[Path, float]]:
|
|
141
|
+
"""Slice full_audio into per-plan chunk files, returning (path, offset) pairs.
|
|
142
|
+
|
|
143
|
+
Uses stream copy (`-c copy`) so there is no re-encode and no quality loss;
|
|
144
|
+
mp3 frame boundaries are close enough for transcription's purposes.
|
|
145
|
+
"""
|
|
146
|
+
if shutil.which("ffmpeg") is None:
|
|
147
|
+
raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
|
|
148
|
+
|
|
149
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
chunks: list[tuple[Path, float]] = []
|
|
151
|
+
for index, (offset, duration) in enumerate(plan):
|
|
152
|
+
out_path = work_dir / f"chunk_{index:03d}.mp3"
|
|
153
|
+
cmd = [
|
|
154
|
+
"ffmpeg",
|
|
155
|
+
"-hide_banner",
|
|
156
|
+
"-loglevel", "error",
|
|
157
|
+
"-y",
|
|
158
|
+
"-ss", f"{offset:.3f}",
|
|
159
|
+
"-i", str(full_audio.resolve()),
|
|
160
|
+
"-t", f"{duration:.3f}",
|
|
161
|
+
"-c", "copy",
|
|
162
|
+
str(out_path.resolve()),
|
|
163
|
+
]
|
|
164
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
165
|
+
if result.returncode != 0 or not out_path.exists() or out_path.stat().st_size == 0:
|
|
166
|
+
raise SystemExit(
|
|
167
|
+
f"ffmpeg failed to split audio chunk {index + 1}: {result.stderr.strip()}"
|
|
168
|
+
)
|
|
169
|
+
chunks.append((out_path, offset))
|
|
170
|
+
return chunks
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _build_multipart(fields: dict[str, str], file_path: Path) -> tuple[bytes, str]:
|
|
174
|
+
"""Assemble a multipart/form-data body the Whisper APIs accept.
|
|
175
|
+
|
|
176
|
+
Whisper's multipart upload is small and predictable — doing it by hand
|
|
177
|
+
keeps us on pure stdlib instead of pulling requests/groq/openai SDKs.
|
|
178
|
+
"""
|
|
179
|
+
boundary = f"----WatchBoundary{uuid.uuid4().hex}"
|
|
180
|
+
eol = b"\r\n"
|
|
181
|
+
buf = io.BytesIO()
|
|
182
|
+
|
|
183
|
+
for name, value in fields.items():
|
|
184
|
+
buf.write(f"--{boundary}".encode())
|
|
185
|
+
buf.write(eol)
|
|
186
|
+
buf.write(f'Content-Disposition: form-data; name="{name}"'.encode())
|
|
187
|
+
buf.write(eol)
|
|
188
|
+
buf.write(eol)
|
|
189
|
+
buf.write(str(value).encode())
|
|
190
|
+
buf.write(eol)
|
|
191
|
+
|
|
192
|
+
mimetype = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
|
193
|
+
buf.write(f"--{boundary}".encode())
|
|
194
|
+
buf.write(eol)
|
|
195
|
+
buf.write(
|
|
196
|
+
f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"'.encode()
|
|
197
|
+
)
|
|
198
|
+
buf.write(eol)
|
|
199
|
+
buf.write(f"Content-Type: {mimetype}".encode())
|
|
200
|
+
buf.write(eol)
|
|
201
|
+
buf.write(eol)
|
|
202
|
+
buf.write(file_path.read_bytes())
|
|
203
|
+
buf.write(eol)
|
|
204
|
+
buf.write(f"--{boundary}--".encode())
|
|
205
|
+
buf.write(eol)
|
|
206
|
+
|
|
207
|
+
return buf.getvalue(), boundary
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
MAX_ATTEMPTS = 4 # initial + 3 retries
|
|
211
|
+
MAX_429_RETRIES = 2
|
|
212
|
+
RETRY_BASE_DELAY = 2.0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _post_whisper(endpoint: str, api_key: str, model: str, audio_path: Path) -> dict:
|
|
216
|
+
fields = {
|
|
217
|
+
"model": model,
|
|
218
|
+
"response_format": "verbose_json",
|
|
219
|
+
"temperature": "0",
|
|
220
|
+
}
|
|
221
|
+
body, boundary = _build_multipart(fields, audio_path)
|
|
222
|
+
headers = {
|
|
223
|
+
"Authorization": f"Bearer {api_key}",
|
|
224
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
225
|
+
# Groq sits behind Cloudflare — the default `Python-urllib/3.x` UA
|
|
226
|
+
# trips WAF rule 1010 (403) before auth even runs. Any non-default
|
|
227
|
+
# UA clears it; we identify honestly.
|
|
228
|
+
"User-Agent": "watch-skill/1.0 (+claude-code; python-urllib)",
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
context = ssl.create_default_context()
|
|
232
|
+
rate_limit_hits = 0
|
|
233
|
+
last_exc: Exception | None = None
|
|
234
|
+
last_detail = ""
|
|
235
|
+
|
|
236
|
+
for attempt in range(MAX_ATTEMPTS):
|
|
237
|
+
request = Request(endpoint, data=body, headers=headers, method="POST")
|
|
238
|
+
try:
|
|
239
|
+
with urlopen(request, timeout=300, context=context) as response:
|
|
240
|
+
payload = response.read().decode("utf-8", errors="replace")
|
|
241
|
+
except urllib.error.HTTPError as exc:
|
|
242
|
+
detail = _read_error_body(exc)
|
|
243
|
+
last_exc, last_detail = exc, detail
|
|
244
|
+
|
|
245
|
+
# 4xx other than 429 are client errors — no retry will fix them.
|
|
246
|
+
if 400 <= exc.code < 500 and exc.code != 429:
|
|
247
|
+
raise SystemExit(f"Whisper request failed: {exc}{detail}") from exc
|
|
248
|
+
|
|
249
|
+
if exc.code == 429:
|
|
250
|
+
rate_limit_hits += 1
|
|
251
|
+
if rate_limit_hits >= MAX_429_RETRIES:
|
|
252
|
+
raise SystemExit(f"Whisper request failed: {exc}{detail}") from exc
|
|
253
|
+
delay = _retry_after(exc) or RETRY_BASE_DELAY * (2 ** attempt) + 1
|
|
254
|
+
else:
|
|
255
|
+
delay = RETRY_BASE_DELAY * (2 ** attempt)
|
|
256
|
+
|
|
257
|
+
if attempt < MAX_ATTEMPTS - 1:
|
|
258
|
+
print(
|
|
259
|
+
f"[watch] whisper HTTP {exc.code} — retrying in {delay:.1f}s "
|
|
260
|
+
f"(attempt {attempt + 2}/{MAX_ATTEMPTS})",
|
|
261
|
+
file=sys.stderr,
|
|
262
|
+
)
|
|
263
|
+
time.sleep(delay)
|
|
264
|
+
continue
|
|
265
|
+
except (urllib.error.URLError, TimeoutError, ConnectionResetError, OSError) as exc:
|
|
266
|
+
last_exc, last_detail = exc, ""
|
|
267
|
+
if attempt < MAX_ATTEMPTS - 1:
|
|
268
|
+
delay = RETRY_BASE_DELAY * (attempt + 1)
|
|
269
|
+
print(
|
|
270
|
+
f"[watch] whisper network error ({type(exc).__name__}: {exc}) — "
|
|
271
|
+
f"retrying in {delay:.1f}s (attempt {attempt + 2}/{MAX_ATTEMPTS})",
|
|
272
|
+
file=sys.stderr,
|
|
273
|
+
)
|
|
274
|
+
time.sleep(delay)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
return json.loads(payload)
|
|
279
|
+
except json.JSONDecodeError as exc:
|
|
280
|
+
raise SystemExit(
|
|
281
|
+
f"Whisper returned non-JSON response: {exc}: {payload[:200]}"
|
|
282
|
+
) from exc
|
|
283
|
+
|
|
284
|
+
raise SystemExit(
|
|
285
|
+
f"Whisper request failed after {MAX_ATTEMPTS} attempts: {last_exc}{last_detail}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _read_error_body(exc: urllib.error.HTTPError) -> str:
|
|
290
|
+
try:
|
|
291
|
+
body = exc.read()
|
|
292
|
+
except Exception:
|
|
293
|
+
return ""
|
|
294
|
+
if not body:
|
|
295
|
+
return ""
|
|
296
|
+
try:
|
|
297
|
+
return f" — {body.decode('utf-8', errors='replace')[:400]}"
|
|
298
|
+
except Exception:
|
|
299
|
+
return ""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _retry_after(exc: urllib.error.HTTPError) -> float | None:
|
|
303
|
+
header = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None
|
|
304
|
+
if not header:
|
|
305
|
+
return None
|
|
306
|
+
try:
|
|
307
|
+
return float(header)
|
|
308
|
+
except ValueError:
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def shift_segments(segments: list[dict], offset_seconds: float) -> list[dict]:
|
|
313
|
+
"""Return a copy of segments with start/end shifted by offset_seconds.
|
|
314
|
+
|
|
315
|
+
Each chunk is transcribed in isolation, so Whisper returns 0-based timestamps
|
|
316
|
+
per chunk; shifting by the chunk's offset stitches them into source time.
|
|
317
|
+
"""
|
|
318
|
+
if offset_seconds == 0:
|
|
319
|
+
return segments
|
|
320
|
+
return [
|
|
321
|
+
{
|
|
322
|
+
"start": round(seg["start"] + offset_seconds, 2),
|
|
323
|
+
"end": round(seg["end"] + offset_seconds, 2),
|
|
324
|
+
"text": seg["text"],
|
|
325
|
+
}
|
|
326
|
+
for seg in segments
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _segments_from_response(data: dict) -> list[dict]:
|
|
331
|
+
"""Convert Whisper verbose_json into our {start, end, text} segment format."""
|
|
332
|
+
out: list[dict] = []
|
|
333
|
+
for seg in data.get("segments") or []:
|
|
334
|
+
text = (seg.get("text") or "").strip()
|
|
335
|
+
if not text:
|
|
336
|
+
continue
|
|
337
|
+
out.append({
|
|
338
|
+
"start": round(float(seg.get("start") or 0.0), 2),
|
|
339
|
+
"end": round(float(seg.get("end") or 0.0), 2),
|
|
340
|
+
"text": text,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
if not out:
|
|
344
|
+
full = (data.get("text") or "").strip()
|
|
345
|
+
if full:
|
|
346
|
+
out.append({"start": 0.0, "end": 0.0, "text": full})
|
|
347
|
+
|
|
348
|
+
return out
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def transcribe_chunks(
|
|
352
|
+
chunks: list[tuple[Path, float]],
|
|
353
|
+
transcribe_one,
|
|
354
|
+
) -> list[dict]:
|
|
355
|
+
"""Transcribe each chunk, shift its segments by the chunk offset, concatenate.
|
|
356
|
+
|
|
357
|
+
A chunk that fails after its own retries is logged and skipped so one bad
|
|
358
|
+
slice doesn't discard the whole transcript. Raises only if every chunk fails.
|
|
359
|
+
"""
|
|
360
|
+
segments: list[dict] = []
|
|
361
|
+
failures = 0
|
|
362
|
+
for index, (path, offset) in enumerate(chunks):
|
|
363
|
+
try:
|
|
364
|
+
chunk_segments = transcribe_one(path)
|
|
365
|
+
except SystemExit as exc:
|
|
366
|
+
failures += 1
|
|
367
|
+
print(
|
|
368
|
+
f"[watch] chunk {index + 1}/{len(chunks)} failed — skipping ({exc})",
|
|
369
|
+
file=sys.stderr,
|
|
370
|
+
)
|
|
371
|
+
continue
|
|
372
|
+
segments.extend(shift_segments(chunk_segments, offset))
|
|
373
|
+
print(
|
|
374
|
+
f"[watch] chunk {index + 1}/{len(chunks)} → {len(chunk_segments)} segments",
|
|
375
|
+
file=sys.stderr,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if failures == len(chunks):
|
|
379
|
+
raise SystemExit("Whisper failed on every audio chunk")
|
|
380
|
+
return segments
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _transcribe_file(backend: str, api_key: str, audio_path: Path) -> list[dict]:
|
|
384
|
+
"""Upload one audio file and return its 0-based segments."""
|
|
385
|
+
if backend == "groq":
|
|
386
|
+
response = _post_whisper(GROQ_ENDPOINT, api_key, GROQ_MODEL, audio_path)
|
|
387
|
+
elif backend == "openai":
|
|
388
|
+
response = _post_whisper(OPENAI_ENDPOINT, api_key, OPENAI_MODEL, audio_path)
|
|
389
|
+
else:
|
|
390
|
+
raise SystemExit(f"Unknown whisper backend: {backend}")
|
|
391
|
+
return _segments_from_response(response)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def transcribe_video(
|
|
395
|
+
video_path: str,
|
|
396
|
+
audio_out: Path,
|
|
397
|
+
backend: str | None = None,
|
|
398
|
+
api_key: str | None = None,
|
|
399
|
+
) -> tuple[list[dict], str]:
|
|
400
|
+
"""Run the full flow: extract audio → upload → parse segments.
|
|
401
|
+
|
|
402
|
+
Returns (segments, backend_used). Raises SystemExit on any failure.
|
|
403
|
+
"""
|
|
404
|
+
if backend is None or api_key is None:
|
|
405
|
+
detected_backend, detected_key = load_api_key()
|
|
406
|
+
backend = backend or detected_backend
|
|
407
|
+
api_key = api_key or detected_key
|
|
408
|
+
|
|
409
|
+
if not backend or not api_key:
|
|
410
|
+
raise SystemExit(
|
|
411
|
+
"No Whisper API key available. Add one with `/arka keys` "
|
|
412
|
+
"(OPENAI_API_KEY or GROQ_API_KEY), or set it in the environment "
|
|
413
|
+
"or in ~/.arkaos/watch.env."
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
print(f"[watch] extracting audio for Whisper ({backend})…", file=sys.stderr)
|
|
417
|
+
audio_path = extract_audio(video_path, audio_out)
|
|
418
|
+
audio_bytes = audio_path.stat().st_size
|
|
419
|
+
|
|
420
|
+
def transcribe_one(path: Path) -> list[dict]:
|
|
421
|
+
return _transcribe_file(backend, api_key, path)
|
|
422
|
+
|
|
423
|
+
if audio_bytes <= MAX_UPLOAD_BYTES:
|
|
424
|
+
print(
|
|
425
|
+
f"[watch] audio: {audio_bytes / 1024:.0f} kB — uploading to {backend} Whisper…",
|
|
426
|
+
file=sys.stderr,
|
|
427
|
+
)
|
|
428
|
+
segments = transcribe_one(audio_path)
|
|
429
|
+
else:
|
|
430
|
+
duration = audio_duration(audio_path)
|
|
431
|
+
plan = plan_chunks(duration, audio_bytes, MAX_UPLOAD_BYTES)
|
|
432
|
+
print(
|
|
433
|
+
f"[watch] audio: {audio_bytes / (1024 * 1024):.0f} MB exceeds "
|
|
434
|
+
f"{MAX_UPLOAD_BYTES // (1024 * 1024)} MB — splitting into {len(plan)} chunks…",
|
|
435
|
+
file=sys.stderr,
|
|
436
|
+
)
|
|
437
|
+
chunks = split_audio(audio_path, audio_out.parent / "chunks", plan)
|
|
438
|
+
segments = transcribe_chunks(chunks, transcribe_one)
|
|
439
|
+
|
|
440
|
+
if not segments:
|
|
441
|
+
raise SystemExit("Whisper returned no transcript segments")
|
|
442
|
+
|
|
443
|
+
print(f"[watch] transcribed {len(segments)} segments via {backend}", file=sys.stderr)
|
|
444
|
+
return segments, backend
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
if __name__ == "__main__":
|
|
448
|
+
if len(sys.argv) < 2:
|
|
449
|
+
print(
|
|
450
|
+
"usage: whisper.py <video-path> [<audio-out.mp3>] [--backend groq|openai]",
|
|
451
|
+
file=sys.stderr,
|
|
452
|
+
)
|
|
453
|
+
raise SystemExit(2)
|
|
454
|
+
|
|
455
|
+
video = sys.argv[1]
|
|
456
|
+
audio_out = (
|
|
457
|
+
Path(sys.argv[2])
|
|
458
|
+
if len(sys.argv) > 2 and not sys.argv[2].startswith("--")
|
|
459
|
+
else Path("audio.mp3")
|
|
460
|
+
)
|
|
461
|
+
backend_override = None
|
|
462
|
+
if "--backend" in sys.argv:
|
|
463
|
+
backend_override = sys.argv[sys.argv.index("--backend") + 1]
|
|
464
|
+
|
|
465
|
+
segments, backend = transcribe_video(video, audio_out, backend=backend_override)
|
|
466
|
+
print(json.dumps({"backend": backend, "segments": segments}, indent=2))
|
|
@@ -85,6 +85,11 @@ When starting fresh, you generate a full set of ad creative based on product con
|
|
|
85
85
|
### Mode 2: Iterate from Performance Data
|
|
86
86
|
When the user provides performance data (CSV, paste, or API output), you analyze what's working, identify patterns in top performers, and generate new variations that build on winning themes while exploring new angles.
|
|
87
87
|
|
|
88
|
+
**Reference or competitor VIDEO ads:** never judge a video ad from its
|
|
89
|
+
thumbnail or description — watch it with the native `dev/watch` skill
|
|
90
|
+
(frames + timestamped transcript) and decompose hook, pacing, on-screen
|
|
91
|
+
text and spoken copy with timestamp citations before cloning the mechanics.
|
|
92
|
+
|
|
88
93
|
The core loop:
|
|
89
94
|
|
|
90
95
|
```
|
package/harness/codex/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ArkaOS — The Operating System for AI Agent Teams
|
|
2
2
|
|
|
3
|
-
> v4.
|
|
3
|
+
> v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
|
|
4
4
|
|
|
5
5
|
You are operating within ArkaOS. Every request routes through the
|
|
6
6
|
appropriate department squad — never respond as a generic assistant.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ArkaOS — The Operating System for AI Agent Teams
|
|
2
2
|
|
|
3
|
-
> v4.
|
|
3
|
+
> v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
|
|
4
4
|
|
|
5
5
|
You are operating within ArkaOS. Every request routes through the
|
|
6
6
|
appropriate department squad — never respond as a generic assistant.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: ArkaOS v4.
|
|
2
|
+
description: ArkaOS v4.32.0 agent-team contract
|
|
3
3
|
alwaysApply: true
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# ArkaOS — The Operating System for AI Agent Teams
|
|
7
7
|
|
|
8
|
-
> v4.
|
|
8
|
+
> v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
|
|
9
9
|
|
|
10
10
|
You are operating within ArkaOS. Every request routes through the
|
|
11
11
|
appropriate department squad — never respond as a generic assistant.
|
package/harness/gemini/GEMINI.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ArkaOS — The Operating System for AI Agent Teams
|
|
2
2
|
|
|
3
|
-
> v4.
|
|
3
|
+
> v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
|
|
4
4
|
|
|
5
5
|
You are operating within ArkaOS. Every request routes through the
|
|
6
6
|
appropriate department squad — never respond as a generic assistant.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ArkaOS — The Operating System for AI Agent Teams
|
|
2
2
|
|
|
3
|
-
> v4.
|
|
3
|
+
> v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
|
|
4
4
|
|
|
5
5
|
You are operating within ArkaOS. Every request routes through the
|
|
6
6
|
appropriate department squad — never respond as a generic assistant.
|
package/harness/zed/.rules
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ArkaOS — The Operating System for AI Agent Teams
|
|
2
2
|
|
|
3
|
-
> v4.
|
|
3
|
+
> v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
|
|
4
4
|
|
|
5
5
|
You are operating within ArkaOS. Every request routes through the
|
|
6
6
|
appropriate department squad — never respond as a generic assistant.
|
package/installer/doctor.js
CHANGED
|
@@ -149,6 +149,31 @@ export function companionPluginsInstalled() {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// /watch media tooling (dev/watch skill). A binary on PATH can still be
|
|
153
|
+
// a corpse — a dangling homebrew dylib makes ffmpeg abort at load (dyld,
|
|
154
|
+
// exit 134) while `which` stays green — so the native binaries are probed
|
|
155
|
+
// with `-version`. yt-dlp is presence-only: probing it costs a Python
|
|
156
|
+
// interpreter start for a breakage mode it doesn't have.
|
|
157
|
+
export function watchMediaTooling() {
|
|
158
|
+
const missing = [];
|
|
159
|
+
for (const bin of ["ffmpeg", "ffprobe"]) {
|
|
160
|
+
if (!commandExists(bin)) {
|
|
161
|
+
missing.push(bin);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
execSync(`${bin} -version`, {
|
|
166
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
167
|
+
timeout: 10000,
|
|
168
|
+
});
|
|
169
|
+
} catch {
|
|
170
|
+
missing.push(`${bin} (broken)`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!commandExists("yt-dlp")) missing.push("yt-dlp");
|
|
174
|
+
return missing;
|
|
175
|
+
}
|
|
176
|
+
|
|
152
177
|
export const checks = [
|
|
153
178
|
{
|
|
154
179
|
name: "install-dir",
|
|
@@ -560,11 +585,20 @@ export const checks = [
|
|
|
560
585
|
fix: () => "Run: npx arkaos install --force",
|
|
561
586
|
},
|
|
562
587
|
{
|
|
563
|
-
|
|
564
|
-
|
|
588
|
+
// Supersedes the presence-only "yt-dlp" check: dev/watch needs
|
|
589
|
+
// ffmpeg/ffprobe too, and needs them RUNNABLE, not just on PATH.
|
|
590
|
+
name: "watch-media-tooling",
|
|
591
|
+
description: "/watch video tooling (ffmpeg, ffprobe, yt-dlp) present and runnable",
|
|
565
592
|
severity: "warn",
|
|
566
|
-
check: () =>
|
|
567
|
-
fix: () =>
|
|
593
|
+
check: () => watchMediaTooling().length === 0,
|
|
594
|
+
fix: () => {
|
|
595
|
+
const missing = watchMediaTooling();
|
|
596
|
+
return (
|
|
597
|
+
`Missing/broken: ${missing.join(", ")}. macOS: brew install ffmpeg yt-dlp ` +
|
|
598
|
+
"(a (broken) entry usually means a dangling dylib — brew reinstall it). " +
|
|
599
|
+
"Or run the dev/watch installer: ~/.claude/skills/arka-watch/scripts/setup.py"
|
|
600
|
+
);
|
|
601
|
+
},
|
|
568
602
|
},
|
|
569
603
|
{
|
|
570
604
|
name: "gotchas",
|
|
@@ -131,6 +131,37 @@ export function installMotionKit({ runtime = "claude-code", home = homedir() } =
|
|
|
131
131
|
return { action: "installed" };
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
function impeccableMarkerPath(home) {
|
|
135
|
+
return join(home, ".arkaos", ".impeccable-installed");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isImpeccableAvailable() {
|
|
139
|
+
const out = spawnSync("impeccable", ["--version"], {
|
|
140
|
+
timeout: 10_000, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8",
|
|
141
|
+
});
|
|
142
|
+
return !out.error && out.status === 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Install the impeccable design detector (the deterministic half of the
|
|
146
|
+
// design-slop Quality Gate check, core/governance/evidence_checks.py).
|
|
147
|
+
// Pinned major (^3.2) — the gate itself never installs (`npx
|
|
148
|
+
// --no-install`), so this installer step is the single supply-chain
|
|
149
|
+
// entry point. Runtime-agnostic (plain npm CLI), idempotent via marker
|
|
150
|
+
// + PATH probe, never-throws.
|
|
151
|
+
export function installImpeccableDetector({ home = homedir() } = {}) {
|
|
152
|
+
if (isImpeccableAvailable()) return { action: "already-present" };
|
|
153
|
+
if (existsSync(impeccableMarkerPath(home))) return { action: "already-present" };
|
|
154
|
+
const out = spawnSync("npm", ["install", "-g", "impeccable@^3.2"], {
|
|
155
|
+
timeout: 180_000, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8",
|
|
156
|
+
});
|
|
157
|
+
if (out.error || out.status !== 0) {
|
|
158
|
+
const reason = (out.stderr || out.error?.message || "unknown").trim().slice(0, 200);
|
|
159
|
+
return { action: "failed", reason };
|
|
160
|
+
}
|
|
161
|
+
try { writeFileSync(impeccableMarkerPath(home), new Date().toISOString()); } catch {}
|
|
162
|
+
return { action: "installed" };
|
|
163
|
+
}
|
|
164
|
+
|
|
134
165
|
// Orchestrate the full frontend tooling setup. Single entry point wired
|
|
135
166
|
// into both installer/index.js and installer/update.js.
|
|
136
167
|
export async function setupFrontendTooling({ runtime = "claude-code", home = homedir() } = {}) {
|
|
@@ -146,5 +177,10 @@ export async function setupFrontendTooling({ runtime = "claude-code", home = hom
|
|
|
146
177
|
} catch (err) {
|
|
147
178
|
results.motionKit = { action: "failed", reason: err.message };
|
|
148
179
|
}
|
|
180
|
+
try {
|
|
181
|
+
results.impeccableDetector = installImpeccableDetector({ home });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
results.impeccableDetector = { action: "failed", reason: err.message };
|
|
184
|
+
}
|
|
149
185
|
return results;
|
|
150
186
|
}
|