blamcode 0.4.0__py3-none-any.whl
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.
- blamcode/__init__.py +9 -0
- blamcode/cli.py +122 -0
- blamcode/layer/README.md +43 -0
- blamcode/layer/config/agent/build.md +118 -0
- blamcode/layer/config/agent/debugger.md +32 -0
- blamcode/layer/config/agent/designer.md +31 -0
- blamcode/layer/config/agent/vision.md +26 -0
- blamcode/layer/config/agent/writer.md +28 -0
- blamcode/layer/config/command/ask.md +41 -0
- blamcode/layer/config/command/blam.md +24 -0
- blamcode/layer/config/command/explore.md +14 -0
- blamcode/layer/config/command/fix.md +14 -0
- blamcode/layer/config/command/open.md +18 -0
- blamcode/layer/config/command/review.md +14 -0
- blamcode/layer/config/opencode.json +53 -0
- blamcode/layer/config/themes/bangladeshi.json +67 -0
- blamcode/layer/config/themes/blamcode.json +217 -0
- blamcode/layer/config/tui.json +4 -0
- blamcode/layer/install.sh +752 -0
- blamcode/layer/scripts/__pycache__/patch-brand.cpython-312.pyc +0 -0
- blamcode/layer/scripts/blamcode +518 -0
- blamcode/layer/scripts/blamcode-browser +315 -0
- blamcode/layer/scripts/blamcode-menu +140 -0
- blamcode/layer/scripts/blamcode-uninstall +71 -0
- blamcode/layer/scripts/blamcode-vision +542 -0
- blamcode/layer/scripts/oc-settings.sh +138 -0
- blamcode/layer/scripts/patch-brand.py +267 -0
- blamcode/layer/skills/android-app/SKILL.md +53 -0
- blamcode/layer/skills/api-integration/SKILL.md +49 -0
- blamcode/layer/skills/bash-cli-expert/SKILL.md +48 -0
- blamcode/layer/skills/bot-development/SKILL.md +53 -0
- blamcode/layer/skills/clean-code-performance/SKILL.md +45 -0
- blamcode/layer/skills/database/SKILL.md +56 -0
- blamcode/layer/skills/debugging-fixes/SKILL.md +45 -0
- blamcode/layer/skills/deploy-hosting/SKILL.md +38 -0
- blamcode/layer/skills/docker/SKILL.md +74 -0
- blamcode/layer/skills/firebase-supabase/SKILL.md +61 -0
- blamcode/layer/skills/git-workflow/SKILL.md +63 -0
- blamcode/layer/skills/lets-scroll/SKILL.md +877 -0
- blamcode/layer/skills/lets-scroll/references/index-template.html +73 -0
- blamcode/layer/skills/lets-scroll/references/knockout.py +89 -0
- blamcode/layer/skills/lets-scroll/references/pipeline.md +312 -0
- blamcode/layer/skills/lets-scroll/references/prompts.md +194 -0
- blamcode/layer/skills/lets-scroll/references/scrub-engine.js +448 -0
- blamcode/layer/skills/project-structure/SKILL.md +74 -0
- blamcode/layer/skills/python-automation/SKILL.md +52 -0
- blamcode/layer/skills/react-next-best-practices/SKILL.md +54 -0
- blamcode/layer/skills/security-review/SKILL.md +48 -0
- blamcode/layer/skills/seo-basics/SKILL.md +44 -0
- blamcode/layer/skills/testing/SKILL.md +58 -0
- blamcode/layer/skills/ui-ux-responsive/SKILL.md +53 -0
- blamcode/layer/skills/website-builder/SKILL.md +47 -0
- blamcode-0.4.0.dist-info/METADATA +62 -0
- blamcode-0.4.0.dist-info/RECORD +58 -0
- blamcode-0.4.0.dist-info/WHEEL +5 -0
- blamcode-0.4.0.dist-info/entry_points.txt +2 -0
- blamcode-0.4.0.dist-info/licenses/LICENSE +21 -0
- blamcode-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
#!/data/data/com.termux/files/usr/bin/bash
|
|
2
|
+
# blamcode-vision — see images via OpenCode Zen (mimo, free) with direct-Gemini fallback
|
|
3
|
+
#
|
|
4
|
+
# MADE FOR BATCHES: give it many images in ONE call — they go out in a few
|
|
5
|
+
# grouped requests instead of a parallel burst (parallel calls are what
|
|
6
|
+
# triggers rate limits).
|
|
7
|
+
# blamcode-vision still1.jpg still2.jpg still3.jpg "compare these"
|
|
8
|
+
# blamcode-vision shot.png (single, no question)
|
|
9
|
+
#
|
|
10
|
+
# Provider chain (first one that answers wins):
|
|
11
|
+
# 1. Zen mimo-v2.5-free — free; rate-limited calls WAIT and retry
|
|
12
|
+
# (20s/40s backoff) before falling through
|
|
13
|
+
# 2. Gemini (direct API) — gemini-3.6-flash -> gemini-flash-latest
|
|
14
|
+
# -> gemini-2.5-flash (only if a key exists)
|
|
15
|
+
# Concurrency: a lock + 2s spacing serializes simultaneous blamcode-vision
|
|
16
|
+
# processes, so the AI can't burst the API even if it tries.
|
|
17
|
+
#
|
|
18
|
+
# Supports: .jpg .jpeg .png .webp .gif .bmp and video (.mp4 .mkv .webm .mov
|
|
19
|
+
# .avi .3gp .m4v — via native Gemini video when a key is set, or
|
|
20
|
+
# ffmpeg frame sampling: 6 evenly-spaced frames -> one image call)
|
|
21
|
+
# Usage:
|
|
22
|
+
# blamcode-vision <image...> [question] (a non-file arg = the question)
|
|
23
|
+
# BLAMCODE_VISION_MODEL=<model> (override primary model)
|
|
24
|
+
# BLAMCODE_VISION_PROVIDER=gemini (skip Zen, go direct)
|
|
25
|
+
# Supports: .jpg .jpeg .png .webp .gif .bmp
|
|
26
|
+
#
|
|
27
|
+
# Keys (found automatically — Zen auths are tried TUI-first):
|
|
28
|
+
# Zen: 1. ~/.local/share/opencode/auth.json zen access_token (OAuth —
|
|
29
|
+
# the same credential the TUI uses; its quota class differs
|
|
30
|
+
# from raw API keys)
|
|
31
|
+
# 2. ~/.local/share/opencode/auth.json zen api key (sk-...)
|
|
32
|
+
# 3. ZEN_API_KEY / OPENCODE_API_KEY (env)
|
|
33
|
+
# 4. ~/.config/blamcode/vision.key (an sk-... key)
|
|
34
|
+
# 5. /proc/*/environ of the running TUI (recovers the key when
|
|
35
|
+
# the bash tool strips env vars; HOME-independent)
|
|
36
|
+
# Gemini: GEMINI_API_KEY (env)
|
|
37
|
+
# > ~/.config/blamcode/vision.key (an AIza... key)
|
|
38
|
+
# HOME is resolved robustly (env > Termux standard > passwd > cwd) — some
|
|
39
|
+
# launch paths arrive with HOME unset and break every ~/ lookup.
|
|
40
|
+
# API keys travel in headers, never in URLs/logs.
|
|
41
|
+
|
|
42
|
+
set -e
|
|
43
|
+
|
|
44
|
+
FILES=()
|
|
45
|
+
PROMPT=""
|
|
46
|
+
for arg in "$@"; do
|
|
47
|
+
if [ -f "$arg" ]; then
|
|
48
|
+
FILES+=("$arg")
|
|
49
|
+
elif [ -z "$PROMPT" ]; then
|
|
50
|
+
PROMPT="$arg"
|
|
51
|
+
else
|
|
52
|
+
echo "❌ Not a file: $arg" >&2
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
done
|
|
56
|
+
|
|
57
|
+
if [ "${#FILES[@]}" -eq 0 ] && [ "${1:-}" != "--status" ]; then
|
|
58
|
+
echo "Usage: blamcode-vision <image...> [question]"
|
|
59
|
+
echo " blamcode-vision --status (show which vision keys were found)"
|
|
60
|
+
echo "Supports: jpg jpeg png webp gif bmp — many images in one call"
|
|
61
|
+
echo "Zen key (optional): echo sk-... > ~/.config/blamcode/vision.key"
|
|
62
|
+
exit 1
|
|
63
|
+
fi
|
|
64
|
+
if [ "${#FILES[@]}" -eq 1 ]; then
|
|
65
|
+
PROMPT="${PROMPT:-Describe this image in detail. Include all objects, colors, text, and layout.}"
|
|
66
|
+
else
|
|
67
|
+
PROMPT="${PROMPT:-Describe each image in detail — objects, colors, text, layout. Start each description with its filename.}"
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
71
|
+
PY=python3
|
|
72
|
+
elif command -v python >/dev/null 2>&1; then
|
|
73
|
+
PY=python
|
|
74
|
+
else
|
|
75
|
+
echo "❌ Python is required for blamcode-vision" >&2
|
|
76
|
+
exit 1
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
exec "$PY" - "$PROMPT" "${FILES[@]}" <<'PYVIS'
|
|
80
|
+
import sys, os, base64, json, time
|
|
81
|
+
import urllib.request, urllib.error
|
|
82
|
+
|
|
83
|
+
def out(s):
|
|
84
|
+
try:
|
|
85
|
+
os.write(1, (str(s) + "\n").encode("utf-8", "replace"))
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
prompt = sys.argv[1]
|
|
90
|
+
paths = [os.path.abspath(p) for p in sys.argv[2:]]
|
|
91
|
+
model_override = os.environ.get("BLAMCODE_VISION_MODEL", "")
|
|
92
|
+
provider = os.environ.get("BLAMCODE_VISION_PROVIDER", "").lower()
|
|
93
|
+
|
|
94
|
+
def get_home():
|
|
95
|
+
"""HOME can arrive empty when spawned from odd launch paths — resolve
|
|
96
|
+
a real one: env > Termux standard > passwd > expanduser > cwd."""
|
|
97
|
+
h = os.environ.get("HOME", "")
|
|
98
|
+
if h and os.path.isabs(h) and os.path.isdir(h):
|
|
99
|
+
return h
|
|
100
|
+
for c in ("/data/data/com.termux/files/home",):
|
|
101
|
+
if os.path.isdir(c):
|
|
102
|
+
return c
|
|
103
|
+
try:
|
|
104
|
+
import pwd
|
|
105
|
+
h = pwd.getpwuid(os.getuid()).pw_dir
|
|
106
|
+
if h and os.path.isabs(h) and os.path.isdir(h):
|
|
107
|
+
return h
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
h = os.path.expanduser("~")
|
|
111
|
+
if os.path.isabs(h) and os.path.isdir(h):
|
|
112
|
+
return h
|
|
113
|
+
return os.getcwd()
|
|
114
|
+
|
|
115
|
+
home = get_home()
|
|
116
|
+
|
|
117
|
+
def env_from_proc():
|
|
118
|
+
"""The TUI's bash tool may strip env vars, but the running opencode
|
|
119
|
+
process carries OPENCODE_API_KEY in its /proc environ (same uid,
|
|
120
|
+
readable). Recover it as a last-resort credential source."""
|
|
121
|
+
try:
|
|
122
|
+
import glob
|
|
123
|
+
for p in sorted(glob.glob("/proc/[0-9]*/environ")):
|
|
124
|
+
try:
|
|
125
|
+
data = open(p, "rb").read()
|
|
126
|
+
except Exception:
|
|
127
|
+
continue
|
|
128
|
+
for kv in data.split(b"\0"):
|
|
129
|
+
if kv.startswith(b"OPENCODE_API_KEY="):
|
|
130
|
+
v = kv.split(b"=", 1)[1].decode("utf-8", "replace").strip()
|
|
131
|
+
if v:
|
|
132
|
+
return v
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
mime_map = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
|
|
138
|
+
"webp": "image/webp", "gif": "image/gif", "bmp": "image/bmp"}
|
|
139
|
+
VIDEO_MIME = {"mp4": "video/mp4", "mkv": "video/x-matroska", "webm": "video/webm",
|
|
140
|
+
"mov": "video/quicktime", "avi": "video/x-msvideo",
|
|
141
|
+
"3gp": "video/3gpp", "m4v": "video/x-m4v"}
|
|
142
|
+
|
|
143
|
+
images = []
|
|
144
|
+
video_paths = []
|
|
145
|
+
for p in paths:
|
|
146
|
+
ext = os.path.splitext(p)[1].lower().lstrip(".")
|
|
147
|
+
if ext in VIDEO_MIME:
|
|
148
|
+
video_paths.append(p)
|
|
149
|
+
continue
|
|
150
|
+
mime = mime_map.get(ext, "image/jpeg")
|
|
151
|
+
size = os.path.getsize(p)
|
|
152
|
+
if size > 20 * 1024 * 1024:
|
|
153
|
+
out(f"[image too large: {os.path.basename(p)} "
|
|
154
|
+
f"({size/1024/1024:.1f} MB — inline limit is 20 MB)]")
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
b64 = base64.b64encode(open(p, "rb").read()).decode()
|
|
157
|
+
images.append({"name": os.path.basename(p), "mime": mime, "b64": b64})
|
|
158
|
+
|
|
159
|
+
# ---- concurrency guard: one blamcode-vision at a time + 2s spacing ----------
|
|
160
|
+
# If the AI fires several vision calls in parallel, they queue up here
|
|
161
|
+
# instead of bursting the API into a rate limit.
|
|
162
|
+
lock_fh = None
|
|
163
|
+
state = os.path.join(home, ".config", "blamcode", "vision.last")
|
|
164
|
+
os.makedirs(os.path.dirname(state), exist_ok=True)
|
|
165
|
+
try:
|
|
166
|
+
import fcntl
|
|
167
|
+
lock_fh = open(os.path.join(home, ".config", "blamcode", "vision.lock"), "w")
|
|
168
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX) # blocks until other instances finish
|
|
169
|
+
except Exception:
|
|
170
|
+
lock_fh = None
|
|
171
|
+
try:
|
|
172
|
+
last = 0.0
|
|
173
|
+
try:
|
|
174
|
+
last = float(open(state).read().strip() or 0)
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
gap = time.time() - last
|
|
178
|
+
if gap < 2.0:
|
|
179
|
+
time.sleep(2.0 - gap)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
def stamp():
|
|
183
|
+
try:
|
|
184
|
+
open(state, "w").write(str(time.time()))
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
# ---- key discovery --------------------------------------------------------
|
|
189
|
+
def read_file(p):
|
|
190
|
+
try:
|
|
191
|
+
return open(p, "r").read().strip()
|
|
192
|
+
except Exception:
|
|
193
|
+
return ""
|
|
194
|
+
|
|
195
|
+
# Zen credentials, tried in order — OAuth token first (the TUI's own route;
|
|
196
|
+
# its quota class is why chat never rate-limits while raw API keys can)
|
|
197
|
+
zen_auths = []
|
|
198
|
+
try:
|
|
199
|
+
auth = json.loads(read_file(os.path.join(home, ".local", "share",
|
|
200
|
+
"opencode", "auth.json")))
|
|
201
|
+
z = auth.get("zen") if isinstance(auth, dict) else None
|
|
202
|
+
if isinstance(z, dict):
|
|
203
|
+
tok = z.get("access_token") or ""
|
|
204
|
+
if isinstance(tok, str) and len(tok) > 20:
|
|
205
|
+
zen_auths.append(tok)
|
|
206
|
+
k = z.get("key") or z.get("api_key") or ""
|
|
207
|
+
if isinstance(k, str) and k.startswith("sk-"):
|
|
208
|
+
zen_auths.append(k)
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
for k in (os.environ.get("ZEN_API_KEY", "").strip(),
|
|
212
|
+
os.environ.get("OPENCODE_API_KEY", "").strip()):
|
|
213
|
+
if k:
|
|
214
|
+
zen_auths.append(k)
|
|
215
|
+
fk = read_file(os.path.join(home, ".config", "blamcode", "vision.key"))
|
|
216
|
+
if fk.startswith("sk-"):
|
|
217
|
+
zen_auths.append(fk)
|
|
218
|
+
# last resort: recover the key from the running TUI process itself
|
|
219
|
+
proc_key = env_from_proc()
|
|
220
|
+
if proc_key and proc_key not in zen_auths:
|
|
221
|
+
zen_auths.append(proc_key)
|
|
222
|
+
_seen = set()
|
|
223
|
+
zen_auths = [a for a in zen_auths if not (a in _seen or _seen.add(a))]
|
|
224
|
+
|
|
225
|
+
gemini_key = os.environ.get("GEMINI_API", "") or os.environ.get("GEMINI_API_KEY", "")
|
|
226
|
+
if not gemini_key:
|
|
227
|
+
fk = read_file(os.path.join(home, ".config", "blamcode", "vision.key"))
|
|
228
|
+
if fk.startswith("AIza"):
|
|
229
|
+
gemini_key = fk
|
|
230
|
+
|
|
231
|
+
# ---- diagnostic mode: blamcode-vision --status -------------------------------
|
|
232
|
+
if prompt == "--status":
|
|
233
|
+
def red(s):
|
|
234
|
+
return f"{s[:8]}…({len(s)} chars)" if len(s) > 12 else "(found)"
|
|
235
|
+
auth_p = os.path.join(home, ".local", "share", "opencode", "auth.json")
|
|
236
|
+
print(f"home: {home}")
|
|
237
|
+
try:
|
|
238
|
+
raw_auth = json.loads(read_file(auth_p))
|
|
239
|
+
z = raw_auth.get("zen") if isinstance(raw_auth, dict) else None
|
|
240
|
+
if isinstance(z, dict):
|
|
241
|
+
print(f"auth.json: present, zen fields: {sorted(z.keys())}")
|
|
242
|
+
elif isinstance(raw_auth, dict):
|
|
243
|
+
print(f"auth.json: present, top-level keys: {sorted(raw_auth.keys())}")
|
|
244
|
+
else:
|
|
245
|
+
print("auth.json: present but not an object")
|
|
246
|
+
except Exception as e:
|
|
247
|
+
print(f"auth.json: NOT readable at {auth_p} ({str(e)[:60]})")
|
|
248
|
+
for var in ("ZEN_API_KEY", "OPENCODE_API_KEY", "GEMINI_API_KEY"):
|
|
249
|
+
v = os.environ.get(var, "")
|
|
250
|
+
print(f"env {var}: {'set: ' + red(v) if v else 'not set'}")
|
|
251
|
+
vk = os.path.join(home, ".config", "blamcode", "vision.key")
|
|
252
|
+
print(f"vision.key: {'present, starts ' + fk[:4] + '…' if fk else 'absent'}")
|
|
253
|
+
print(f"home resolved to: {home}")
|
|
254
|
+
print(f"/proc environ recovery: {'found ' + red(proc_key) if proc_key else 'nothing found'}")
|
|
255
|
+
print(f"zen credentials discovered: {len(zen_auths)}")
|
|
256
|
+
for i, a in enumerate(zen_auths, 1):
|
|
257
|
+
print(f" {i}. {red(a)}")
|
|
258
|
+
print(f"gemini fallback: {'armed' if gemini_key else 'no key'}")
|
|
259
|
+
sys.exit(0)
|
|
260
|
+
|
|
261
|
+
# ---- videos: native Gemini first, else ffmpeg frame sampling -------------
|
|
262
|
+
def call_gemini_video(model, vids, question):
|
|
263
|
+
import urllib.request, urllib.error, json as _json
|
|
264
|
+
url = (f"https://generativelanguage.googleapis.com/v1beta/models/"
|
|
265
|
+
f"{model}:generateContent")
|
|
266
|
+
parts = [{"text": question}]
|
|
267
|
+
for v in vids:
|
|
268
|
+
ext = os.path.splitext(v)[1].lower().lstrip(".")
|
|
269
|
+
mime = VIDEO_MIME.get(ext, "video/mp4")
|
|
270
|
+
b64v = base64.b64encode(open(v, "rb").read()).decode()
|
|
271
|
+
parts.append({"inline_data": {"mime_type": mime, "data": b64v}})
|
|
272
|
+
payload = {"contents": [{"parts": parts}]}
|
|
273
|
+
req = urllib.request.Request(url, data=_json.dumps(payload).encode(),
|
|
274
|
+
headers={"Content-Type": "application/json",
|
|
275
|
+
"x-goog-api-key": gemini_key})
|
|
276
|
+
with urllib.request.urlopen(req, timeout=180) as r:
|
|
277
|
+
d = _json.loads(r.read().decode())
|
|
278
|
+
return d["candidates"][0]["content"]["parts"][0]["text"]
|
|
279
|
+
|
|
280
|
+
def extract_frames(video, ff):
|
|
281
|
+
import subprocess, shutil, tempfile
|
|
282
|
+
tmp = tempfile.mkdtemp(prefix="blamcode-vision-")
|
|
283
|
+
dur = None
|
|
284
|
+
fp = shutil.which("ffprobe")
|
|
285
|
+
if fp:
|
|
286
|
+
try:
|
|
287
|
+
r = subprocess.run([fp, "-v", "error", "-show_entries",
|
|
288
|
+
"format=duration", "-of", "csv=p=0", video],
|
|
289
|
+
capture_output=True, text=True, timeout=30)
|
|
290
|
+
dur = float(r.stdout.strip())
|
|
291
|
+
except Exception:
|
|
292
|
+
dur = None
|
|
293
|
+
n = 6
|
|
294
|
+
if dur and dur > 0.5:
|
|
295
|
+
stamps = [min(dur * (i + 0.5) / n, max(dur - 0.1, 0.0)) for i in range(n)]
|
|
296
|
+
else:
|
|
297
|
+
stamps = [float(i) for i in range(n)]
|
|
298
|
+
frames = []
|
|
299
|
+
for i, t in enumerate(stamps):
|
|
300
|
+
out_f = os.path.join(tmp, f"f{i:02d}.jpg")
|
|
301
|
+
try:
|
|
302
|
+
subprocess.run([ff, "-ss", f"{t:.2f}", "-i", video, "-frames:v", "1",
|
|
303
|
+
"-q:v", "4", out_f, "-y"], capture_output=True,
|
|
304
|
+
timeout=60)
|
|
305
|
+
except Exception:
|
|
306
|
+
continue
|
|
307
|
+
if os.path.exists(out_f) and os.path.getsize(out_f) > 0:
|
|
308
|
+
frames.append((out_f, t))
|
|
309
|
+
return frames
|
|
310
|
+
|
|
311
|
+
video_note_added = False
|
|
312
|
+
video_ok = False
|
|
313
|
+
if video_paths:
|
|
314
|
+
remaining = list(video_paths)
|
|
315
|
+
# route 1: Gemini understands video natively — no ffmpeg needed
|
|
316
|
+
if gemini_key and provider != "zen":
|
|
317
|
+
small = [v for v in remaining
|
|
318
|
+
if os.path.getsize(v) <= 20 * 1024 * 1024]
|
|
319
|
+
big = [v for v in remaining if v not in small]
|
|
320
|
+
for v in big:
|
|
321
|
+
out(f"[{os.path.basename(v)}: over 20 MB — Gemini inline limit; "
|
|
322
|
+
f"compress or trim it]")
|
|
323
|
+
if small:
|
|
324
|
+
vq = ("Describe each video in detail — what happens over time, "
|
|
325
|
+
"scene changes, motion, text on screen.")
|
|
326
|
+
for gm in (["gemini-3.6-flash", "gemini-flash-latest",
|
|
327
|
+
"gemini-2.5-flash"] if not model_override
|
|
328
|
+
else [model_override]):
|
|
329
|
+
try:
|
|
330
|
+
text = call_gemini_video(gm, small, vq)
|
|
331
|
+
stamp()
|
|
332
|
+
out(f"🎬 {' + '.join(os.path.basename(v) for v in small)} "
|
|
333
|
+
f"(gemini {gm}):")
|
|
334
|
+
out("")
|
|
335
|
+
out(text)
|
|
336
|
+
remaining = big
|
|
337
|
+
video_ok = True
|
|
338
|
+
break
|
|
339
|
+
except Exception:
|
|
340
|
+
continue
|
|
341
|
+
# route 2: sample frames with ffmpeg and send them as images
|
|
342
|
+
if remaining:
|
|
343
|
+
import shutil, subprocess
|
|
344
|
+
ff = shutil.which("ffmpeg")
|
|
345
|
+
ff_working = False
|
|
346
|
+
if ff:
|
|
347
|
+
try:
|
|
348
|
+
r = subprocess.run([ff, "-version"], capture_output=True,
|
|
349
|
+
timeout=15)
|
|
350
|
+
ff_working = (r.returncode == 0)
|
|
351
|
+
except Exception:
|
|
352
|
+
ff_working = False
|
|
353
|
+
for v in remaining:
|
|
354
|
+
base = os.path.basename(v)
|
|
355
|
+
if not ff:
|
|
356
|
+
out(f"[{base}: video needs ffmpeg (pkg install ffmpeg) or a "
|
|
357
|
+
f"free Gemini key — skipping]")
|
|
358
|
+
elif not ff_working:
|
|
359
|
+
out(f"[{base}: ffmpeg is broken — fix with "
|
|
360
|
+
f"'pkg reinstall ffmpeg', or set a free Gemini key "
|
|
361
|
+
f"(GEMINI_API_KEY) for native video — skipping]")
|
|
362
|
+
else:
|
|
363
|
+
frames = extract_frames(v, ff)
|
|
364
|
+
if not frames:
|
|
365
|
+
out(f"[{base}: could not extract frames — skipping]")
|
|
366
|
+
continue
|
|
367
|
+
for fp_, t in frames:
|
|
368
|
+
b64f = base64.b64encode(open(fp_, "rb").read()).decode()
|
|
369
|
+
images.append({"name": f"{base}@{t:.1f}s",
|
|
370
|
+
"mime": "image/jpeg", "b64": b64f})
|
|
371
|
+
if not video_note_added:
|
|
372
|
+
prompt += (" (Some images are frames sampled from "
|
|
373
|
+
"video(s) — describe what happens over "
|
|
374
|
+
"time.)")
|
|
375
|
+
video_note_added = True
|
|
376
|
+
if not images:
|
|
377
|
+
# videos only — exit status reflects whether anything was seen
|
|
378
|
+
sys.exit(0 if video_ok else 1)
|
|
379
|
+
|
|
380
|
+
# ---- batching: max 8 images / 12 MB base64 per request --------------------
|
|
381
|
+
batches, cur, cur_bytes = [], [], 0
|
|
382
|
+
for img in images:
|
|
383
|
+
if cur and (len(cur) >= 8 or cur_bytes + len(img["b64"]) > 12 * 1024 * 1024):
|
|
384
|
+
batches.append(cur)
|
|
385
|
+
cur, cur_bytes = [], 0
|
|
386
|
+
cur.append(img)
|
|
387
|
+
cur_bytes += len(img["b64"])
|
|
388
|
+
if cur:
|
|
389
|
+
batches.append(cur)
|
|
390
|
+
|
|
391
|
+
# ---- provider chain -------------------------------------------------------
|
|
392
|
+
zen_models = [model_override or "mimo-v2.5-free"]
|
|
393
|
+
gemini_models = []
|
|
394
|
+
for m in ([model_override] if model_override else []) + \
|
|
395
|
+
["gemini-3.6-flash", "gemini-flash-latest", "gemini-2.5-flash"]:
|
|
396
|
+
if m not in gemini_models:
|
|
397
|
+
gemini_models.append(m)
|
|
398
|
+
|
|
399
|
+
chain = []
|
|
400
|
+
if provider != "gemini":
|
|
401
|
+
for auth in zen_auths:
|
|
402
|
+
chain += [("zen", m, auth) for m in zen_models]
|
|
403
|
+
if provider != "zen" and gemini_key:
|
|
404
|
+
chain += [("gemini", m) for m in gemini_models]
|
|
405
|
+
if not chain:
|
|
406
|
+
out("[no vision key found — vision needs one of these:]")
|
|
407
|
+
out(" Zen (recommended — the same free key chat uses):")
|
|
408
|
+
out(" already works when OPENCODE_API_KEY is set, or")
|
|
409
|
+
out(" echo sk-YOURKEY > ~/.config/blamcode/vision.key")
|
|
410
|
+
out(" Gemini: export GEMINI_API_KEY=AIza... (free key: aistudio.google.com)")
|
|
411
|
+
sys.exit(1)
|
|
412
|
+
|
|
413
|
+
def call_zen(model, batch, auth):
|
|
414
|
+
url = "https://opencode.ai/zen/v1/chat/completions"
|
|
415
|
+
content = [{"type": "text", "text": prompt}]
|
|
416
|
+
for img in batch:
|
|
417
|
+
content.append({"type": "image_url", "image_url":
|
|
418
|
+
{"url": f"data:{img['mime']};base64,{img['b64']}"}})
|
|
419
|
+
payload = {"model": model, "messages": [{"role": "user", "content": content}]}
|
|
420
|
+
req = urllib.request.Request(url, data=json.dumps(payload).encode(),
|
|
421
|
+
headers={"Content-Type": "application/json",
|
|
422
|
+
"Authorization": f"Bearer {auth}",
|
|
423
|
+
# Zen keys the free quota to the
|
|
424
|
+
# opencode client id — BLAMCODE is a
|
|
425
|
+
# rebranded opencode, so this is
|
|
426
|
+
# our real identity. Other UAs
|
|
427
|
+
# get a permanently-exhausted
|
|
428
|
+
# anonymous bucket (429 always).
|
|
429
|
+
"User-Agent": "opencode/1.0.0"})
|
|
430
|
+
with urllib.request.urlopen(req, timeout=120) as r:
|
|
431
|
+
d = json.loads(r.read().decode())
|
|
432
|
+
return d["choices"][0]["message"]["content"]
|
|
433
|
+
|
|
434
|
+
def call_gemini(model, batch):
|
|
435
|
+
url = (f"https://generativelanguage.googleapis.com/v1beta/models/"
|
|
436
|
+
f"{model}:generateContent")
|
|
437
|
+
parts = [{"text": prompt}]
|
|
438
|
+
for img in batch:
|
|
439
|
+
parts.append({"inline_data": {"mime_type": img["mime"],
|
|
440
|
+
"data": img["b64"]}})
|
|
441
|
+
payload = {"contents": [{"parts": parts}]}
|
|
442
|
+
req = urllib.request.Request(url, data=json.dumps(payload).encode(),
|
|
443
|
+
headers={"Content-Type": "application/json",
|
|
444
|
+
"x-goog-api-key": gemini_key})
|
|
445
|
+
with urllib.request.urlopen(req, timeout=90) as r:
|
|
446
|
+
d = json.loads(r.read().decode())
|
|
447
|
+
return d["candidates"][0]["content"]["parts"][0]["text"]
|
|
448
|
+
|
|
449
|
+
def fatal(msg):
|
|
450
|
+
out(msg)
|
|
451
|
+
names = ", ".join(f"{e[0]}:{e[1]}" for e in chain)
|
|
452
|
+
out(f"[blamcode-vision failed — tried: {names}]")
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
|
|
455
|
+
def run_batch(batch, first):
|
|
456
|
+
"""Send one image batch through the provider chain.
|
|
457
|
+
Rate-limited providers WAIT and retry before falling through."""
|
|
458
|
+
for i, entry in enumerate(chain):
|
|
459
|
+
prov, model = entry[0], entry[1]
|
|
460
|
+
zen_auth = entry[2] if len(entry) > 2 else None
|
|
461
|
+
is_last = i == len(chain) - 1
|
|
462
|
+
if prov == "zen":
|
|
463
|
+
# patient backoff: free limits usually clear in seconds-minutes
|
|
464
|
+
for attempt in range(3):
|
|
465
|
+
try:
|
|
466
|
+
text = call_zen(model, batch, zen_auth)
|
|
467
|
+
stamp()
|
|
468
|
+
label = f" ({model})" if first else ""
|
|
469
|
+
out(f"📷 {' + '.join(im['name'] for im in batch)}{label}:")
|
|
470
|
+
out("")
|
|
471
|
+
out(text)
|
|
472
|
+
return
|
|
473
|
+
except urllib.error.HTTPError as e:
|
|
474
|
+
body = ""
|
|
475
|
+
try:
|
|
476
|
+
body = e.read().decode()[:200]
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
if e.code == 401 and not is_last:
|
|
480
|
+
# expired OAuth token / bad key — next credential now
|
|
481
|
+
out("[auth rejected — trying next credential]")
|
|
482
|
+
break
|
|
483
|
+
limited = e.code == 429 or "Limit" in body or "Credits" in body
|
|
484
|
+
if limited and not is_last:
|
|
485
|
+
# a different credential/route has its own quota
|
|
486
|
+
# bucket — try it before burning backoff time
|
|
487
|
+
out(f"[{model}: rate limited — trying next route]")
|
|
488
|
+
break
|
|
489
|
+
if limited and attempt < 2:
|
|
490
|
+
out(f"[{model}: rate limited — retrying in 3s "
|
|
491
|
+
f"(try {attempt + 2}/3)]")
|
|
492
|
+
time.sleep(3)
|
|
493
|
+
continue
|
|
494
|
+
if not is_last:
|
|
495
|
+
out(f"[{model}: unavailable (HTTP {e.code}) — "
|
|
496
|
+
f"falling back]")
|
|
497
|
+
break
|
|
498
|
+
fatal(f"[Zen error: HTTP {e.code} {body}]")
|
|
499
|
+
except Exception as e:
|
|
500
|
+
if not is_last:
|
|
501
|
+
out(f"[{model}: {str(e)[:80]} — falling back]")
|
|
502
|
+
break
|
|
503
|
+
fatal(f"[Zen error: {str(e)[:120]}]")
|
|
504
|
+
else:
|
|
505
|
+
note = None
|
|
506
|
+
for attempt in range(3):
|
|
507
|
+
try:
|
|
508
|
+
text = call_gemini(model, batch)
|
|
509
|
+
stamp()
|
|
510
|
+
out(f"📷 {' + '.join(im['name'] for im in batch)}:")
|
|
511
|
+
out("")
|
|
512
|
+
out(text)
|
|
513
|
+
return
|
|
514
|
+
except urllib.error.HTTPError as e:
|
|
515
|
+
try:
|
|
516
|
+
err = json.loads(e.read().decode())
|
|
517
|
+
msg = err.get("error", {}).get("message", "")
|
|
518
|
+
except Exception:
|
|
519
|
+
msg = ""
|
|
520
|
+
pretty = msg[:150] if msg else f"HTTP {e.code}"
|
|
521
|
+
if e.code == 429: # rate limited — wait, retry same model
|
|
522
|
+
if attempt < 2:
|
|
523
|
+
time.sleep(3)
|
|
524
|
+
continue
|
|
525
|
+
fatal("[Gemini rate limited — try again in a minute]")
|
|
526
|
+
if e.code in (400, 404) and not is_last:
|
|
527
|
+
note = f"[{model} unavailable — falling back]"
|
|
528
|
+
break
|
|
529
|
+
fatal(f"[Gemini error: {pretty}]")
|
|
530
|
+
except Exception as e:
|
|
531
|
+
fatal(f"[Gemini error: {str(e)[:120]}]")
|
|
532
|
+
if note:
|
|
533
|
+
out(note)
|
|
534
|
+
fatal("[all vision providers failed]")
|
|
535
|
+
|
|
536
|
+
for bi, batch in enumerate(batches):
|
|
537
|
+
if bi > 0:
|
|
538
|
+
out("")
|
|
539
|
+
time.sleep(2) # breathing room between batches
|
|
540
|
+
run_batch(batch, bi == 0)
|
|
541
|
+
sys.exit(0)
|
|
542
|
+
PYVIS
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/data/data/com.termux/files/usr/bin/bash
|
|
2
|
+
# oc-settings — BLAMCODE model tier switcher
|
|
3
|
+
# Usage:
|
|
4
|
+
# oc-settings (menu)
|
|
5
|
+
# oc-settings model (menu: max/mid/ultra/tiny)
|
|
6
|
+
# oc-settings model <tier>
|
|
7
|
+
# oc-settings apply (rewrites the config)
|
|
8
|
+
set -e
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR="${HOME}/.config/opencode"
|
|
11
|
+
AGENT="build"
|
|
12
|
+
USERNAME="blamcode-dev"
|
|
13
|
+
|
|
14
|
+
# Model ids are passed to the Zen API WITHOUT a provider prefix — the
|
|
15
|
+
# API only knows bare ids like "deepseek-v4-flash-free" (a prefixed
|
|
16
|
+
# "blamcode/deepseek-..." gets rejected with "not valid type").
|
|
17
|
+
|
|
18
|
+
MODEL="deepseek-v4-flash-free"
|
|
19
|
+
SMALL="laguna-s-2.1-free"
|
|
20
|
+
|
|
21
|
+
saved_model() {
|
|
22
|
+
[ -f "$CONFIG_DIR/opencode.json" ] && sed -n 's/.*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CONFIG_DIR/opencode.json" | head -n1
|
|
23
|
+
}
|
|
24
|
+
saved_small() {
|
|
25
|
+
[ -f "$CONFIG_DIR/opencode.json" ] && sed -n 's/.*"small_model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CONFIG_DIR/opencode.json" | head -n1
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Surgical JSON edit: only the model keys change; everything else
|
|
29
|
+
# (provider, apiKey, auth, permission) is preserved. Both opencode.json and
|
|
30
|
+
# opencode.jsonc are updated so config merging can't override the choice.
|
|
31
|
+
apply_model() { # $1 = model, $2 = small_model
|
|
32
|
+
python3 - "$CONFIG_DIR" "$USERNAME" "$AGENT" "$1" "$2" <<'PY'
|
|
33
|
+
import json, os, sys
|
|
34
|
+
d, user, agent, model, small = sys.argv[1:6]
|
|
35
|
+
files = [os.path.join(d, "opencode.json"), os.path.join(d, "opencode.jsonc")]
|
|
36
|
+
cfg = {}
|
|
37
|
+
for f in files:
|
|
38
|
+
if os.path.exists(f):
|
|
39
|
+
try:
|
|
40
|
+
cfg = json.load(open(f))
|
|
41
|
+
break
|
|
42
|
+
except Exception:
|
|
43
|
+
continue
|
|
44
|
+
cfg.setdefault("username", user)
|
|
45
|
+
cfg.setdefault("default_agent", agent)
|
|
46
|
+
if model:
|
|
47
|
+
cfg["model"] = model
|
|
48
|
+
if small:
|
|
49
|
+
cfg["small_model"] = small
|
|
50
|
+
os.makedirs(d, exist_ok=True)
|
|
51
|
+
for f in files:
|
|
52
|
+
with open(f, "w") as fh:
|
|
53
|
+
json.dump(cfg, fh, indent=2)
|
|
54
|
+
fh.write("\n")
|
|
55
|
+
PY
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
pick_model() {
|
|
59
|
+
local prev="$1"
|
|
60
|
+
local TIER_ARG="${2:-}"
|
|
61
|
+
case "$TIER_ARG" in
|
|
62
|
+
max|flash|fast|default|full) MODEL="deepseek-v4-flash-free"; SMALL="laguna-s-2.1-free"; return;;
|
|
63
|
+
lightning) MODEL="nemotron-3.5-lightning-free"; SMALL="laguna-s-2.1-free"; return;;
|
|
64
|
+
mid|medium) MODEL="mimo-v2.5-free"; SMALL="laguna-s-2.1-free"; return;;
|
|
65
|
+
ultra|strong) MODEL="nemotron-3-ultra-free"; SMALL="laguna-s-2.1-free"; return;;
|
|
66
|
+
*) ;;
|
|
67
|
+
esac
|
|
68
|
+
echo
|
|
69
|
+
echo " Select a model tier:"
|
|
70
|
+
echo " 1) Max (default) : deepseek-v4-flash-free [full-power, stable]"
|
|
71
|
+
echo " 2) Lightning : nemotron-3.5-lightning-free"
|
|
72
|
+
echo " 3) Mid : mimo-v2.5-free"
|
|
73
|
+
echo " 4) Nemotron Ultra : nemotron-3-ultra-free [! provider errors reported — experimental]"
|
|
74
|
+
echo " 5) Custom ID (e.g. gpt-5.5)"
|
|
75
|
+
echo -n " [1-5, Enter keeps - $prev]: "
|
|
76
|
+
read -r CHOICE || CHOICE=0
|
|
77
|
+
case "$CHOICE" in
|
|
78
|
+
1) MODEL="deepseek-v4-flash-free"
|
|
79
|
+
SMALL="laguna-s-2.1-free";;
|
|
80
|
+
2) MODEL="nemotron-3.5-lightning-free"
|
|
81
|
+
SMALL="laguna-s-2.1-free";;
|
|
82
|
+
3) MODEL="mimo-v2.5-free"
|
|
83
|
+
SMALL="laguna-s-2.1-free";;
|
|
84
|
+
4) MODEL="nemotron-3-ultra-free"
|
|
85
|
+
SMALL="laguna-s-2.1-free"
|
|
86
|
+
echo " Warning: provider errors have been reported for this model — if it does not work, run: oc-settings model max" ;;
|
|
87
|
+
5) echo -n " Model ID: "; read -r CUSTOM
|
|
88
|
+
[ -n "$CUSTOM" ] && MODEL="$CUSTOM"
|
|
89
|
+
echo -n " Small Model ID (Enter keeps current): "; read -r CSMALL
|
|
90
|
+
[ -n "$CSMALL" ] && SMALL="$CSMALL";;
|
|
91
|
+
*) echo " Keeping the current selection.";;
|
|
92
|
+
esac
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case "${1:-}" in
|
|
96
|
+
model|ultra|strong|max|flash|fast|lightning|mid|medium|default)
|
|
97
|
+
pick_model "$MODEL" "${2:-}"
|
|
98
|
+
apply_model "$MODEL" "$SMALL"
|
|
99
|
+
echo
|
|
100
|
+
echo "Config updated (model + saved settings preserved):"
|
|
101
|
+
echo " model: $MODEL"
|
|
102
|
+
echo " small_model: $SMALL"
|
|
103
|
+
echo "NOTE: restart blamcode (config is loaded at startup)."
|
|
104
|
+
;;
|
|
105
|
+
apply)
|
|
106
|
+
apply_model "$(saved_model)" "$(saved_small)"
|
|
107
|
+
echo
|
|
108
|
+
echo "Config re-written (existing model/provider preserved)."
|
|
109
|
+
echo "NOTE: restart blamcode (config is loaded at startup)."
|
|
110
|
+
;;
|
|
111
|
+
ls|models|list)
|
|
112
|
+
echo "Free zen models:"
|
|
113
|
+
curl -fsSL -m 15 "https://opencode.ai/zen/v1/models" 2>/dev/null \
|
|
114
|
+
| grep -o '"id":"[^"]*free[^"]*"' | sed 's/.*"id":"\([^"]*\)".*/\1/' || echo "(network problem)"
|
|
115
|
+
;;
|
|
116
|
+
-h|--help|help)
|
|
117
|
+
sed -n '2,9p' "$0"
|
|
118
|
+
;;
|
|
119
|
+
"")
|
|
120
|
+
echo
|
|
121
|
+
echo " BLAMCODE settings"
|
|
122
|
+
echo " 1) Model tier (max / mid / ultra / tiny)"
|
|
123
|
+
echo " 2) Free models list"
|
|
124
|
+
echo -n " [1-2]: "
|
|
125
|
+
read -r CHOICE || CHOICE=""
|
|
126
|
+
case "$CHOICE" in
|
|
127
|
+
1) pick_model "$MODEL"; apply_model "$MODEL" "$SMALL"
|
|
128
|
+
echo; echo " model: $MODEL — restart blamcode.";;
|
|
129
|
+
2) "$0" models;;
|
|
130
|
+
*) echo " Bye.";;
|
|
131
|
+
esac
|
|
132
|
+
;;
|
|
133
|
+
*)
|
|
134
|
+
echo "Unknown: $1"
|
|
135
|
+
sed -n '2,9p' "$0"
|
|
136
|
+
exit 1
|
|
137
|
+
;;
|
|
138
|
+
esac
|