diff --git a/cmd_chat/cardex.py b/cmd_chat/cardex.py
index 863d239..d8b5ae6 100644
--- a/cmd_chat/cardex.py
+++ b/cmd_chat/cardex.py
@@ -32,12 +32,41 @@ BASE_MB = 128.0 # ~size of the empty hh-dev base image; payload = size
SUBSCORE_MAX = 100 # completeness+reusability+richness+pedigree+heft caps sum
POWER_MAX = 1000 # PowerScore = subscore_sum * 10
+# Sub-score caps (sum == SUBSCORE_MAX). completeness/reusability are near-binary
+# "is it a real, shipped VM?" gates, so they're kept small; the discriminating
+# signal lives in richness (how self-describing + how rare its capabilities),
+# heft (installed payload), and pedigree — those carry most of the spread.
+COMP_MAX = 20.0
+REUSE_MAX = 15.0
+RICH_MAX = 30.0
+PED_MAX = 10.0
+HEFT_MAX = 25.0
+
+# Per-tag capability weight — rare/high-skill security capabilities are worth
+# more than generic ones, so a VM's "type rarity" pushes its richness (and thus
+# its rarity tier). Unlisted tags default to 1.5.
+CAPABILITY_WEIGHT = {
+ # marquee / rare offensive + IR capabilities
+ "exploit": 3.0, "pwn": 3.0, "c2": 3.0, "payload": 3.0, "malware": 3.0,
+ "crack": 3.0, "redteam": 3.0, "dfir": 3.0, "forensics": 3.0, "reverse": 3.0,
+ "ransomware": 3.0,
+ # solid mid-tier capabilities
+ "recon": 2.0, "osint": 2.0, "fuzz": 2.0, "brute": 2.0, "crypto": 2.0,
+ "tls": 2.0, "dns": 2.0, "yara": 2.0, "pcap": 2.0, "vuln": 2.0, "cve": 2.0,
+ "ids": 2.0, "threat": 2.0, "nmap": 2.0,
+ # generic / scaffolding tags
+ "dev": 1.0, "build": 1.0, "lib": 1.0, "http": 1.0, "web": 1.0, "log": 1.0,
+ "network": 1.0, "audit": 1.0, "blue": 1.0, "training": 1.0, "lab": 1.0,
+ "security": 1.0, "smoke": 0.0, "slice": 0.0,
+}
+
# Rarity by absolute PowerScore cutoff (user choice: stable & reproducible).
+# Calibrated against the live library distribution so tiers actually spread.
RARITY_TIERS = [
- ("Legendary", 825),
- ("Epic", 650),
- ("Rare", 450),
- ("Uncommon", 250),
+ ("Legendary", 720),
+ ("Epic", 660),
+ ("Rare", 540),
+ ("Uncommon", 380),
("Common", 0),
]
@@ -82,37 +111,47 @@ def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
# ── the five value sub-scores ────────────────────────────────────────────────
def _completeness(status: str, todo: list, blockers: list) -> float:
+ """Is it a real, finished VM? Near-binary gate; small cap by design."""
s = (status or "").strip().lower()
- base = 25.0 if s in ("done", "complete", "completed") else (12.0 if s == "in_progress" else 0.0)
+ base = COMP_MAX if s in ("done", "complete", "completed") else (10.0 if s == "in_progress" else 0.0)
base -= min(len(todo) * 3, 12)
base -= len(blockers) * 5
- return _clamp(base, 0, 25)
+ return _clamp(base, 0, COMP_MAX)
def _reusability(shareable: bool, share_path: str, artifact_kind: str) -> float:
- v = 14.0 if (shareable and share_path) else 0.0
- v += {"file": 6.0, "image": 2.0, "snapshot": 2.0}.get(artifact_kind, 0.0)
- return _clamp(v, 0, 20)
+ """Can another agent actually pull and reuse it?"""
+ v = 10.0 if (shareable and share_path) else 0.0
+ v += {"file": 5.0, "image": 3.0, "snapshot": 2.0}.get(artifact_kind, 0.0)
+ return _clamp(v, 0, REUSE_MAX)
def _richness(purpose: str, objective: str, tags: list, has_usage: bool) -> float:
+ """The discriminating axis: how self-describing + how rare its capabilities.
+
+ Rewards prose depth (purpose/objective length), tag breadth, *and* the
+ capability-rarity of those tags (a c2/exploit VM out-scores a dev-lib one)."""
v = 0.0
- v += 5 if purpose.strip() else 0
- v += 5 if objective.strip() else 0
- v += min(len(tags) * 2, 6)
- v += 4 if has_usage else 0
- return _clamp(v, 0, 20)
+ v += min(len(purpose.split()) * 0.8, 8) # purpose prose depth
+ v += 5 if objective.strip() else 0 # has a stated objective
+ v += min(len(tags) * 1.5, 9) # tag breadth
+ cap = sum(CAPABILITY_WEIGHT.get(t.strip().lower(), 1.5) for t in tags)
+ v += min(cap, 8) # capability rarity
+ v += 4 if has_usage else 0 # documents how to run
+ return _clamp(v, 0, RICH_MAX)
def _pedigree(provenance: list, created_by: str) -> float:
- v = min(len(provenance) * 3, 12)
- v += 3 if created_by.strip().lower() not in ("", "smoke", "pulled") else 0
- return _clamp(v, 0, 15)
+ """How battle-tested / hand-built its lineage is."""
+ v = min(len(provenance) * 3, 8)
+ v += 2 if created_by.strip().lower() not in ("", "smoke", "pulled") else 0
+ return _clamp(v, 0, PED_MAX)
def _heft(size_bytes: int) -> float:
+ """Installed payload over the empty base — log-scaled so giants don't run away."""
payload_mb = max(0.0, (size_bytes or 0) / 1e6 - BASE_MB)
- return _clamp(4.0 * math.log2(1 + payload_mb / 8.0), 0, 20)
+ return _clamp(5.0 * math.log2(1 + payload_mb / 6.0), 0, HEFT_MAX)
def _rarity_of(power: int) -> str:
@@ -226,12 +265,12 @@ def card_from_entry(entry: dict, manifest: dict | None = None) -> Card:
payload_mb = max(0.0, (entry.get("size_bytes") or 0) / 1e6 - BASE_MB)
speed_raw = _clamp(1 - payload_mb / 256)
stats = {
- "HP": _stat(floor + 0.70 * (0.6 * heft / 20 + 0.4 * comp / 25)),
+ "HP": _stat(floor + 0.70 * (0.6 * heft / HEFT_MAX + 0.4 * comp / COMP_MAX)),
"Attack": _stat(floor + 0.70 * off),
- "Defense": _stat(floor + 0.70 * (0.5 * dfn + 0.5 * comp / 25)),
- "SpAtk": _stat(floor + 0.70 * (rich / 20)),
+ "Defense": _stat(floor + 0.70 * (0.5 * dfn + 0.5 * comp / COMP_MAX)),
+ "SpAtk": _stat(floor + 0.70 * (rich / RICH_MAX)),
"Speed": _stat(floor + 0.70 * speed_raw),
- "SpDef": _stat(floor + 0.70 * (0.6 * reuse / 20 + 0.4 * ped / 15)),
+ "SpDef": _stat(floor + 0.70 * (0.6 * reuse / REUSE_MAX + 0.4 * ped / PED_MAX)),
}
shiny = (_seed(label) >> 16) % 16 == 0 # ~1/16, the classic shiny odds
diff --git a/cmd_chat/cardimg.py b/cmd_chat/cardimg.py
new file mode 100644
index 0000000..33a8be3
--- /dev/null
+++ b/cmd_chat/cardimg.py
@@ -0,0 +1,410 @@
+"""hh-cardimg — render a cardex Card as a collectible trading-card image.
+
+Free + local-first by design, with paid art backends wired in as opt-in options:
+
+ art backend cost needs what it draws in the art window
+ ─────────── ────── ─────────────── ───────────────────────────────
+ sigil free nothing a deterministic procedural creature
+ (offline) glyph rendered in pure SVG (default)
+ pollinations free network Pollinations flux text-to-image (no key)
+ stability paid STABILITY_API_KEY Stability "stable-image core"
+ openai paid OPENAI_API_KEY OpenAI gpt-image-1
+ runway paid RUNWAYML_API_SECRET RunwayML gen4_image (async task)
+
+The card *frame* (border, name, type badges, 6 stat bars, dex#, rarity holo,
+flavor) is always hand-built SVG — no backend touches it, so a card always
+renders fully offline; the chosen backend only fills the portrait window. The
+SVG is self-contained (any fetched art is inlined as a base64 data URI) and is
+rasterized to PNG locally via `cairosvg` or ImageMagick `convert` when present.
+
+Everything is deterministic and seeded by the VM label: the same VM mints the
+same card art (same Pollinations/paid seed, same sigil geometry).
+
+ python -m cmd_chat.cardimg --label net-mapper-kit --art sigil --format png
+ python -m cmd_chat.cardimg --all --art pollinations --out cards/
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import json
+import math
+import os
+import shutil
+import subprocess
+import sys
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+from .cardex import Card, card_from_entry, load_entries, _registry_path, _seed
+
+# ── card geometry ─────────────────────────────────────────────────────────────
+W, H = 500, 700
+PAD = 18
+ART_X, ART_Y, ART_W, ART_H = PAD + 8, 96, W - 2 * (PAD + 8), 300
+
+# rarity → frame palette (border, inner glow, accent text)
+RARITY_THEME = {
+ "Common": ("#8a8f98", "#3a3f47", "#c8ccd2"),
+ "Uncommon": ("#3fae5a", "#13351f", "#9be8ae"),
+ "Rare": ("#3f7fd6", "#102742", "#9cc3f4"),
+ "Epic": ("#9a4fd0", "#2a1140", "#d6a8f4"),
+ "Legendary": ("#e0a020", "#3a2705", "#ffd86b"),
+}
+# elemental type → badge color
+TYPE_COLOR = {
+ "Psychic": "#d44d8a", "Dark": "#5a5366", "Fire": "#e0622a", "Electric": "#e0b62a",
+ "Ghost": "#7766b8", "Steel": "#8a96a6", "Flying": "#6fa8dc", "Dragon": "#5a4fd0",
+ "Normal": "#a0a4ab", "Ground": "#c8a05a", "Water": "#3f8fd6",
+}
+# type → a couple of visual descriptors for the AI-art prompt
+TYPE_VISUAL = {
+ "Psychic": "luminous psionic aura, third eye, violet energy",
+ "Dark": "shadowy, obsidian carapace, smoke wreathed",
+ "Fire": "molten cracks, ember plumes, scorched scales",
+ "Electric": "crackling arcs, neon circuitry, charged spines",
+ "Ghost": "spectral, translucent, drifting wisps",
+ "Steel": "armored plating, riveted chrome, bladed edges",
+ "Flying": "feathered wings, wind currents, aerodynamic",
+ "Dragon": "ancient draconic, horned, scaled wings",
+ "Normal": "earthen, sturdy, understated",
+ "Ground": "rocky hide, sediment, tectonic",
+ "Water": "fluid, iridescent fins, deep currents",
+}
+STAT_ORDER = [("HP", "HP"), ("Attack", "ATK"), ("Defense", "DEF"),
+ ("SpAtk", "SpA"), ("Speed", "SPE"), ("SpDef", "SpD")]
+
+
+# ── procedural creature sigil (free, offline, deterministic) ──────────────────
+def _sigil_svg(card: Card) -> str:
+ """A deterministic radial creature glyph drawn from the label seed.
+
+ No model, no network: a symmetric burst of limbs + an eye, colored by type.
+ Distinct per VM (geometry, limb count, eye) yet stable across runs."""
+ h = _seed(card.label)
+ cx, cy = ART_X + ART_W / 2, ART_Y + ART_H / 2
+ R = min(ART_W, ART_H) * 0.34
+ col = TYPE_COLOR.get(card.types[0], "#a0a4ab")
+ col2 = TYPE_COLOR.get(card.types[-1], col)
+ limbs = 5 + (h % 6) # 5..10 radial limbs
+ twist = (h >> 4) % 360
+ parts = [f''
+ f''
+ f''
+ f'']
+ # radial limbs
+ for i in range(limbs):
+ ang = math.radians(twist + i * 360.0 / limbs)
+ seg = 3 + ((h >> (i + 2)) % 3)
+ pts = []
+ for s in range(seg + 1):
+ rr = R * (0.35 + 0.75 * s / seg)
+ wob = math.radians(((h >> (i + s)) % 40) - 20)
+ x = cx + rr * math.cos(ang + wob)
+ y = cy + rr * math.sin(ang + wob)
+ pts.append(f"{x:.1f},{y:.1f}")
+ parts.append(f'')
+ # body
+ parts.append(f'')
+ # eye(s)
+ eyes = 1 + (h % 3)
+ for e in range(eyes):
+ ex = cx + (e - (eyes - 1) / 2) * R * 0.36
+ ey = cy - R * 0.06
+ parts.append(f'')
+ parts.append(f'')
+ return "".join(parts)
+
+
+# ── AI-art prompt (deterministic text from the card) ──────────────────────────
+def build_prompt(card: Card) -> str:
+ ty = card.types[0]
+ vis = ", ".join(TYPE_VISUAL.get(t, "") for t in card.types if TYPE_VISUAL.get(t))
+ grandeur = {
+ "Common": "humble small creature",
+ "Uncommon": "spirited creature",
+ "Rare": "powerful beast",
+ "Epic": "fearsome legendary-class monster",
+ "Legendary": "god-tier mythical titan, epic scale, awe-inspiring",
+ }[card.rarity]
+ return (f"a {grandeur} named {card.name}, {ty}-type mythical creature, {vis}, "
+ f"centered character portrait, fantasy trading-card creature art, "
+ f"dramatic rim lighting, clean dark background, highly detailed, "
+ f"digital painting, no text, no border")
+
+
+# ── art backends: return raw image bytes (PNG/JPEG) or raise ──────────────────
+def _http(req: urllib.request.Request, timeout=120) -> bytes:
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return r.read()
+
+
+def _art_pollinations(card: Card) -> bytes:
+ """Free hosted flux text-to-image — no API key."""
+ prompt = urllib.parse.quote(build_prompt(card))
+ seed = _seed(card.label) % 1_000_000
+ url = (f"https://image.pollinations.ai/prompt/{prompt}"
+ f"?width={ART_W*2}&height={ART_H*2}&seed={seed}&nologo=true&model=flux")
+ return _http(urllib.request.Request(url, headers={"User-Agent": "hh-cardimg"}))
+
+
+def _art_stability(card: Card) -> bytes:
+ key = os.environ.get("STABILITY_API_KEY")
+ if not key:
+ raise RuntimeError("STABILITY_API_KEY not set")
+ boundary = "----hhcardimg"
+ parts, body = [], b""
+ fields = {"prompt": build_prompt(card), "output_format": "png",
+ "aspect_ratio": "1:1", "seed": str(_seed(card.label) % 4_000_000_000)}
+ for k, v in fields.items():
+ parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n')
+ body = ("".join(parts) + f"--{boundary}--\r\n").encode()
+ req = urllib.request.Request(
+ "https://api.stability.ai/v2beta/stable-image/generate/core", data=body,
+ headers={"Authorization": f"Bearer {key}", "Accept": "image/*",
+ "Content-Type": f"multipart/form-data; boundary={boundary}"})
+ return _http(req)
+
+
+def _art_openai(card: Card) -> bytes:
+ key = os.environ.get("OPENAI_API_KEY")
+ if not key:
+ raise RuntimeError("OPENAI_API_KEY not set")
+ payload = json.dumps({"model": "gpt-image-1", "prompt": build_prompt(card),
+ "size": "1024x1024", "n": 1}).encode()
+ req = urllib.request.Request(
+ "https://api.openai.com/v1/images/generations", data=payload,
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
+ out = json.loads(_http(req))
+ return base64.b64decode(out["data"][0]["b64_json"])
+
+
+def _art_runway(card: Card) -> bytes:
+ """RunwayML gen4_image — async task: create, poll, download."""
+ key = os.environ.get("RUNWAYML_API_SECRET")
+ if not key:
+ raise RuntimeError("RUNWAYML_API_SECRET not set")
+ hdr = {"Authorization": f"Bearer {key}", "Content-Type": "application/json",
+ "X-Runway-Version": "2024-11-06"}
+ payload = json.dumps({"model": "gen4_image", "ratio": "1024:1024",
+ "promptText": build_prompt(card)}).encode()
+ task = json.loads(_http(urllib.request.Request(
+ "https://api.dev.runwayml.com/v1/text_to_image", data=payload, headers=hdr)))
+ tid = task["id"]
+ import time
+ for _ in range(60):
+ time.sleep(3)
+ st = json.loads(_http(urllib.request.Request(
+ f"https://api.dev.runwayml.com/v1/tasks/{tid}", headers=hdr)))
+ if st.get("status") == "SUCCEEDED":
+ return _http(urllib.request.Request(st["output"][0]))
+ if st.get("status") in ("FAILED", "CANCELLED"):
+ raise RuntimeError(f"runway task {st.get('status')}: {st.get('failure','')}")
+ raise RuntimeError("runway task timed out")
+
+
+ART_BACKENDS = {
+ "sigil": None, # handled inline (no fetch)
+ "pollinations": _art_pollinations,
+ "stability": _art_stability,
+ "openai": _art_openai,
+ "runway": _art_runway,
+}
+
+
+def fetch_art_data_uri(card: Card, backend: str) -> str | None:
+ """Return a base64 data-URI for the portrait, or None to use the sigil."""
+ fn = ART_BACKENDS.get(backend)
+ if fn is None:
+ return None
+ raw = fn(card)
+ mime = "image/png"
+ if raw[:3] == b"\xff\xd8\xff":
+ mime = "image/jpeg"
+ return f"data:{mime};base64," + base64.b64encode(raw).decode()
+
+
+# ── SVG card frame ────────────────────────────────────────────────────────────
+def _esc(s: str) -> str:
+ return (s.replace("&", "&").replace("<", "<").replace(">", ">"))
+
+
+def _wrap(s: str, n: int) -> list[str]:
+ out, line = [], ""
+ for w in s.split():
+ if len(line) + len(w) + 1 > n:
+ out.append(line)
+ line = w
+ else:
+ line = (line + " " + w).strip()
+ if line:
+ out.append(line)
+ return out[:3]
+
+
+def render_card_svg(card: Card, art_data_uri: str | None = None) -> str:
+ border, glow, accent = RARITY_THEME.get(card.rarity, RARITY_THEME["Common"])
+ s = card.stats
+ bst = sum(s.values())
+ p = [f'")
+ return "".join(p)
+
+
+# ── rasterize SVG → PNG locally ───────────────────────────────────────────────
+def svg_to_png(svg_path: Path, png_path: Path) -> bool:
+ if shutil.which("cairosvg"):
+ subprocess.run(["cairosvg", str(svg_path), "-o", str(png_path)], check=True)
+ return True
+ conv = shutil.which("convert") or shutil.which("magick")
+ if conv:
+ subprocess.run([conv, "-density", "192", "-background", "none",
+ str(svg_path), str(png_path)], check=True)
+ return True
+ return False
+
+
+def render_card(card: Card, out_dir: Path, backend: str, fmt: str) -> Path:
+ out_dir.mkdir(parents=True, exist_ok=True)
+ art_uri = None
+ if backend != "sigil":
+ try:
+ art_uri = fetch_art_data_uri(card, backend)
+ except Exception as e: # graceful: fall back to sigil
+ print(f" ! {card.label}: {backend} art failed ({e}); using sigil",
+ file=sys.stderr)
+ svg = render_card_svg(card, art_uri)
+ svg_path = out_dir / f"{card.label}.svg"
+ svg_path.write_text(svg)
+ if fmt == "svg":
+ return svg_path
+ png_path = out_dir / f"{card.label}.png"
+ if svg_to_png(svg_path, png_path):
+ return png_path
+ print(" ! no local SVG rasterizer (cairosvg/convert); kept SVG", file=sys.stderr)
+ return svg_path
+
+
+# ── CLI ───────────────────────────────────────────────────────────────────────
+def main(argv=None) -> int:
+ p = argparse.ArgumentParser(prog="hh-cardimg",
+ description="Render cardex VM cards as images.")
+ p.add_argument("--registry", default=str(_registry_path()))
+ p.add_argument("--label", help="one VM (default: all)")
+ p.add_argument("--all", action="store_true", help="render every VM in the registry")
+ p.add_argument("--art", default="sigil", choices=list(ART_BACKENDS),
+ help="portrait backend (default: sigil = free + offline)")
+ p.add_argument("--format", default="png", choices=["png", "svg"])
+ p.add_argument("--out", default="cards", help="output directory")
+ p.add_argument("--prompt", action="store_true",
+ help="just print the AI-art prompt(s) and exit")
+ args = p.parse_args(argv)
+
+ entries = load_entries(Path(args.registry))
+ if args.label:
+ entries = [e for e in entries if e.get("label") == args.label]
+ if not entries:
+ print(f"no VM labelled '{args.label}'")
+ return 1
+ elif not args.all:
+ print("specify --label or --all")
+ return 2
+
+ cards = [card_from_entry(e) for e in entries]
+ if args.prompt:
+ for c in cards:
+ print(f"# {c.label} [{c.rarity} {('/'.join(c.types))}]\n{build_prompt(c)}\n")
+ return 0
+
+ out = Path(args.out)
+ for c in cards:
+ path = render_card(c, out, args.art, args.format)
+ print(f"{c.rarity:<10} {c.label:<22} → {path}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())