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,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bradley Bonanno
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared /watch configuration helpers (ArkaOS-native).
|
|
3
|
+
|
|
4
|
+
Every filesystem anchor resolves at CALL time, never import time, so a
|
|
5
|
+
redirected HOME (tests, sandboxes) always takes effect. Key resolution
|
|
6
|
+
order: environment -> ~/.arkaos/keys.json (managed by `/arka keys`) ->
|
|
7
|
+
~/.arkaos/watch.env -> ./.env.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
DEFAULT_DETAIL = "balanced"
|
|
18
|
+
|
|
19
|
+
DETAILS = {"transcript", "efficient", "balanced", "token-burner"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def arkaos_home() -> Path:
|
|
23
|
+
"""User-data root — ~/.arkaos, or ARKAOS_HOME when set."""
|
|
24
|
+
override = os.environ.get("ARKAOS_HOME")
|
|
25
|
+
if override:
|
|
26
|
+
return Path(override).expanduser()
|
|
27
|
+
return Path.home() / ".arkaos"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def config_file() -> Path:
|
|
31
|
+
return arkaos_home() / "watch.env"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def keys_file() -> Path:
|
|
35
|
+
return arkaos_home() / "keys.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def telemetry_file() -> Path:
|
|
39
|
+
return arkaos_home() / "telemetry" / "watch-usage.jsonl"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_env_file(path: Path | None = None) -> dict[str, str]:
|
|
43
|
+
if path is None:
|
|
44
|
+
path = config_file()
|
|
45
|
+
values: dict[str, str] = {}
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return values
|
|
48
|
+
try:
|
|
49
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
50
|
+
except OSError:
|
|
51
|
+
return values
|
|
52
|
+
for line in lines:
|
|
53
|
+
raw = line.strip()
|
|
54
|
+
if not raw or raw.startswith("#") or "=" not in raw:
|
|
55
|
+
continue
|
|
56
|
+
key, _, value = raw.partition("=")
|
|
57
|
+
value = value.strip()
|
|
58
|
+
if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]:
|
|
59
|
+
value = value[1:-1]
|
|
60
|
+
else:
|
|
61
|
+
# Strip an inline comment (a '#' preceded by whitespace) from an
|
|
62
|
+
# unquoted value. Without this, `WATCH_DETAIL=balanced # note`
|
|
63
|
+
# parses as "balanced # note", fails validation, and silently
|
|
64
|
+
# falls back to the default. Keeps '#' inside quotes / API keys.
|
|
65
|
+
for i, ch in enumerate(value):
|
|
66
|
+
if ch == "#" and i > 0 and value[i - 1] in " \t":
|
|
67
|
+
value = value[:i].rstrip()
|
|
68
|
+
break
|
|
69
|
+
values[key.strip()] = value
|
|
70
|
+
return values
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_keys_json() -> dict[str, str]:
|
|
74
|
+
"""API keys stored by `/arka keys` (~/.arkaos/keys.json). Fail-open."""
|
|
75
|
+
path = keys_file()
|
|
76
|
+
if not path.exists():
|
|
77
|
+
return {}
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
80
|
+
except (OSError, ValueError):
|
|
81
|
+
return {}
|
|
82
|
+
if not isinstance(data, dict):
|
|
83
|
+
return {}
|
|
84
|
+
return {str(k): str(v) for k, v in data.items() if v}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_api_key(name: str) -> str | None:
|
|
88
|
+
"""env -> ~/.arkaos/keys.json -> ~/.arkaos/watch.env -> ./.env."""
|
|
89
|
+
value = os.environ.get(name)
|
|
90
|
+
if value and value.strip():
|
|
91
|
+
return value.strip()
|
|
92
|
+
value = read_keys_json().get(name)
|
|
93
|
+
if value and value.strip():
|
|
94
|
+
return value.strip()
|
|
95
|
+
for path in (config_file(), Path.cwd() / ".env"):
|
|
96
|
+
value = read_env_file(path).get(name)
|
|
97
|
+
if value and value.strip():
|
|
98
|
+
return value.strip()
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_config() -> dict[str, object]:
|
|
103
|
+
file_values = read_env_file()
|
|
104
|
+
|
|
105
|
+
detail = (
|
|
106
|
+
os.environ.get("WATCH_DETAIL")
|
|
107
|
+
or file_values.get("WATCH_DETAIL")
|
|
108
|
+
or DEFAULT_DETAIL
|
|
109
|
+
)
|
|
110
|
+
if detail not in DETAILS:
|
|
111
|
+
detail = DEFAULT_DETAIL
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"detail": detail,
|
|
115
|
+
"config_file": str(config_file()),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def frame_cap(detail: str) -> int | None:
|
|
120
|
+
if detail == "efficient":
|
|
121
|
+
return 50
|
|
122
|
+
if detail == "balanced":
|
|
123
|
+
return 100
|
|
124
|
+
if detail == "token-burner":
|
|
125
|
+
return None
|
|
126
|
+
if detail == "transcript":
|
|
127
|
+
return None
|
|
128
|
+
return 100
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def record_telemetry(event: dict) -> None:
|
|
132
|
+
"""Append one usage record to ~/.arkaos/telemetry/watch-usage.jsonl.
|
|
133
|
+
|
|
134
|
+
Observability must never break a watch run: filesystem failures
|
|
135
|
+
(OSError) are reported to stderr and swallowed (fail-open). Callers
|
|
136
|
+
pass only JSON-serializable scalars, so serialization cannot raise.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
path = telemetry_file()
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
record = {"ts": round(time.time(), 3), **event}
|
|
142
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
143
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
144
|
+
except OSError as exc:
|
|
145
|
+
print(f"[watch] telemetry write skipped: {exc}", file=sys.stderr)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Download a video via yt-dlp, or resolve a local file path.
|
|
3
|
+
|
|
4
|
+
Also fetches subtitles (manual first, then auto-generated) in VTT format so
|
|
5
|
+
transcribe.py can parse them without needing Whisper.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
VIDEO_EXTS = {".mp4", ".mkv", ".webm", ".mov", ".m4v", ".avi", ".flv", ".wmv"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_url(source: str) -> bool:
|
|
20
|
+
if source.startswith("-"):
|
|
21
|
+
return False
|
|
22
|
+
parsed = urlparse(source)
|
|
23
|
+
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_local(path: str) -> dict:
|
|
27
|
+
p = Path(path).expanduser().resolve()
|
|
28
|
+
if not p.exists():
|
|
29
|
+
raise SystemExit(f"File not found: {p}")
|
|
30
|
+
if p.suffix.lower() not in VIDEO_EXTS:
|
|
31
|
+
print(
|
|
32
|
+
f"[watch] warning: {p.suffix} is not a known video extension, proceeding anyway",
|
|
33
|
+
file=sys.stderr,
|
|
34
|
+
)
|
|
35
|
+
return {
|
|
36
|
+
"video_path": str(p),
|
|
37
|
+
"subtitle_path": None,
|
|
38
|
+
"info": {"title": p.name, "url": str(p)},
|
|
39
|
+
"downloaded": False,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _pick_subtitle(out_dir: Path) -> Path | None:
|
|
44
|
+
candidates = sorted(out_dir.glob("video*.vtt"))
|
|
45
|
+
if not candidates:
|
|
46
|
+
return None
|
|
47
|
+
preferred = [
|
|
48
|
+
c for c in candidates
|
|
49
|
+
if any(marker in c.name for marker in (".en.", ".en-US.", ".en-GB.", ".en-orig."))
|
|
50
|
+
]
|
|
51
|
+
return preferred[0] if preferred else candidates[0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _pick_video(out_dir: Path) -> Path | None:
|
|
55
|
+
for ext in (".mp4", ".mkv", ".webm", ".mov", ".m4a", ".mp3", ".opus"):
|
|
56
|
+
for candidate in out_dir.glob(f"video*{ext}"):
|
|
57
|
+
return candidate
|
|
58
|
+
for candidate in out_dir.glob("video.*"):
|
|
59
|
+
if candidate.suffix.lower() in VIDEO_EXTS:
|
|
60
|
+
return candidate
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fetch_captions(url: str, out_dir: Path) -> dict:
|
|
65
|
+
"""Fetch metadata and best available VTT captions without downloading video."""
|
|
66
|
+
if shutil.which("yt-dlp") is None:
|
|
67
|
+
raise SystemExit("yt-dlp is not installed. Install with: brew install yt-dlp")
|
|
68
|
+
|
|
69
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
output_template = str(out_dir / "video.%(ext)s")
|
|
71
|
+
cmd = [
|
|
72
|
+
"yt-dlp",
|
|
73
|
+
"--skip-download",
|
|
74
|
+
"--write-info-json",
|
|
75
|
+
"--write-subs",
|
|
76
|
+
"--write-auto-subs",
|
|
77
|
+
"--sub-langs", "en.*",
|
|
78
|
+
"--sub-format", "vtt",
|
|
79
|
+
"--convert-subs", "vtt",
|
|
80
|
+
"--no-playlist",
|
|
81
|
+
"--ignore-errors",
|
|
82
|
+
"-o", output_template,
|
|
83
|
+
"--",
|
|
84
|
+
url,
|
|
85
|
+
]
|
|
86
|
+
subprocess.run(cmd, stdout=sys.stderr, stderr=sys.stderr)
|
|
87
|
+
subtitle = _pick_subtitle(out_dir)
|
|
88
|
+
info = _read_info(out_dir / "video.info.json", url)
|
|
89
|
+
return {
|
|
90
|
+
"video_path": None,
|
|
91
|
+
"subtitle_path": str(subtitle) if subtitle else None,
|
|
92
|
+
"info": info or {"url": url},
|
|
93
|
+
"downloaded": False,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_info(info_path: Path, url: str) -> dict:
|
|
98
|
+
info: dict = {}
|
|
99
|
+
if info_path.exists():
|
|
100
|
+
try:
|
|
101
|
+
raw = json.loads(info_path.read_text(encoding="utf-8"))
|
|
102
|
+
info = {
|
|
103
|
+
"title": raw.get("title"),
|
|
104
|
+
"uploader": raw.get("uploader") or raw.get("channel"),
|
|
105
|
+
"duration": raw.get("duration"),
|
|
106
|
+
"url": raw.get("webpage_url") or url,
|
|
107
|
+
}
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
print(f"[watch] info.json parse failed: {exc}", file=sys.stderr)
|
|
110
|
+
info = {"url": url}
|
|
111
|
+
return info
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def download_url(
|
|
115
|
+
url: str,
|
|
116
|
+
out_dir: Path,
|
|
117
|
+
audio_only: bool = False,
|
|
118
|
+
) -> dict:
|
|
119
|
+
if shutil.which("yt-dlp") is None:
|
|
120
|
+
raise SystemExit("yt-dlp is not installed. Install with: brew install yt-dlp")
|
|
121
|
+
|
|
122
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
output_template = str(out_dir / "video.%(ext)s")
|
|
124
|
+
|
|
125
|
+
fmt = "ba/bestaudio" if audio_only else "bv*[height<=720]+ba/b[height<=720]/bv+ba/b"
|
|
126
|
+
cmd = [
|
|
127
|
+
"yt-dlp",
|
|
128
|
+
"-N", "8",
|
|
129
|
+
"-f", fmt,
|
|
130
|
+
"--merge-output-format", "mp4",
|
|
131
|
+
"--write-info-json",
|
|
132
|
+
"--write-subs",
|
|
133
|
+
"--write-auto-subs",
|
|
134
|
+
"--sub-langs", "en.*",
|
|
135
|
+
"--sub-format", "vtt",
|
|
136
|
+
"--convert-subs", "vtt",
|
|
137
|
+
"--no-playlist",
|
|
138
|
+
"--ignore-errors",
|
|
139
|
+
"-o", output_template,
|
|
140
|
+
"--",
|
|
141
|
+
url,
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
# yt-dlp may exit non-zero if a subtitle variant fails (e.g. 429) even when
|
|
145
|
+
# the video itself downloaded fine. Treat "video file present" as success.
|
|
146
|
+
result = subprocess.run(cmd, stdout=sys.stderr, stderr=sys.stderr)
|
|
147
|
+
video = _pick_video(out_dir)
|
|
148
|
+
if video is None:
|
|
149
|
+
raise SystemExit(
|
|
150
|
+
f"yt-dlp did not produce a video file in {out_dir} (exit {result.returncode})"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
subtitle = _pick_subtitle(out_dir)
|
|
154
|
+
info = _read_info(out_dir / "video.info.json", url)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"video_path": str(video),
|
|
158
|
+
"subtitle_path": str(subtitle) if subtitle else None,
|
|
159
|
+
"info": info or {"url": url},
|
|
160
|
+
"downloaded": True,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def download(
|
|
165
|
+
source: str,
|
|
166
|
+
out_dir: Path,
|
|
167
|
+
audio_only: bool = False,
|
|
168
|
+
) -> dict:
|
|
169
|
+
if is_url(source):
|
|
170
|
+
return download_url(source, out_dir, audio_only=audio_only)
|
|
171
|
+
return resolve_local(source)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
if len(sys.argv) < 3:
|
|
176
|
+
print("usage: download.py <url-or-path> <out-dir>", file=sys.stderr)
|
|
177
|
+
raise SystemExit(2)
|
|
178
|
+
result = download(sys.argv[1], Path(sys.argv[2]))
|
|
179
|
+
print(json.dumps(result, indent=2))
|