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.
Files changed (58) hide show
  1. blamcode/__init__.py +9 -0
  2. blamcode/cli.py +122 -0
  3. blamcode/layer/README.md +43 -0
  4. blamcode/layer/config/agent/build.md +118 -0
  5. blamcode/layer/config/agent/debugger.md +32 -0
  6. blamcode/layer/config/agent/designer.md +31 -0
  7. blamcode/layer/config/agent/vision.md +26 -0
  8. blamcode/layer/config/agent/writer.md +28 -0
  9. blamcode/layer/config/command/ask.md +41 -0
  10. blamcode/layer/config/command/blam.md +24 -0
  11. blamcode/layer/config/command/explore.md +14 -0
  12. blamcode/layer/config/command/fix.md +14 -0
  13. blamcode/layer/config/command/open.md +18 -0
  14. blamcode/layer/config/command/review.md +14 -0
  15. blamcode/layer/config/opencode.json +53 -0
  16. blamcode/layer/config/themes/bangladeshi.json +67 -0
  17. blamcode/layer/config/themes/blamcode.json +217 -0
  18. blamcode/layer/config/tui.json +4 -0
  19. blamcode/layer/install.sh +752 -0
  20. blamcode/layer/scripts/__pycache__/patch-brand.cpython-312.pyc +0 -0
  21. blamcode/layer/scripts/blamcode +518 -0
  22. blamcode/layer/scripts/blamcode-browser +315 -0
  23. blamcode/layer/scripts/blamcode-menu +140 -0
  24. blamcode/layer/scripts/blamcode-uninstall +71 -0
  25. blamcode/layer/scripts/blamcode-vision +542 -0
  26. blamcode/layer/scripts/oc-settings.sh +138 -0
  27. blamcode/layer/scripts/patch-brand.py +267 -0
  28. blamcode/layer/skills/android-app/SKILL.md +53 -0
  29. blamcode/layer/skills/api-integration/SKILL.md +49 -0
  30. blamcode/layer/skills/bash-cli-expert/SKILL.md +48 -0
  31. blamcode/layer/skills/bot-development/SKILL.md +53 -0
  32. blamcode/layer/skills/clean-code-performance/SKILL.md +45 -0
  33. blamcode/layer/skills/database/SKILL.md +56 -0
  34. blamcode/layer/skills/debugging-fixes/SKILL.md +45 -0
  35. blamcode/layer/skills/deploy-hosting/SKILL.md +38 -0
  36. blamcode/layer/skills/docker/SKILL.md +74 -0
  37. blamcode/layer/skills/firebase-supabase/SKILL.md +61 -0
  38. blamcode/layer/skills/git-workflow/SKILL.md +63 -0
  39. blamcode/layer/skills/lets-scroll/SKILL.md +877 -0
  40. blamcode/layer/skills/lets-scroll/references/index-template.html +73 -0
  41. blamcode/layer/skills/lets-scroll/references/knockout.py +89 -0
  42. blamcode/layer/skills/lets-scroll/references/pipeline.md +312 -0
  43. blamcode/layer/skills/lets-scroll/references/prompts.md +194 -0
  44. blamcode/layer/skills/lets-scroll/references/scrub-engine.js +448 -0
  45. blamcode/layer/skills/project-structure/SKILL.md +74 -0
  46. blamcode/layer/skills/python-automation/SKILL.md +52 -0
  47. blamcode/layer/skills/react-next-best-practices/SKILL.md +54 -0
  48. blamcode/layer/skills/security-review/SKILL.md +48 -0
  49. blamcode/layer/skills/seo-basics/SKILL.md +44 -0
  50. blamcode/layer/skills/testing/SKILL.md +58 -0
  51. blamcode/layer/skills/ui-ux-responsive/SKILL.md +53 -0
  52. blamcode/layer/skills/website-builder/SKILL.md +47 -0
  53. blamcode-0.4.0.dist-info/METADATA +62 -0
  54. blamcode-0.4.0.dist-info/RECORD +58 -0
  55. blamcode-0.4.0.dist-info/WHEEL +5 -0
  56. blamcode-0.4.0.dist-info/entry_points.txt +2 -0
  57. blamcode-0.4.0.dist-info/licenses/LICENSE +21 -0
  58. 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