Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces.
|
||||
|
||||
Petdex (https://github.com/crafter-station/petdex) is a public gallery of
|
||||
animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a
|
||||
``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use
|
||||
an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas.
|
||||
Hermes infers the row taxonomy from the sheet and maps agent activity onto
|
||||
idle/run/review/failed/wave/jump.
|
||||
|
||||
This package is the **single source of truth** for the feature so the base
|
||||
CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard
|
||||
parts:
|
||||
|
||||
- :mod:`agent.pet.constants` — frame geometry + the :class:`PetState` enum.
|
||||
- :mod:`agent.pet.state` — map agent activity → a :class:`PetState`.
|
||||
- :mod:`agent.pet.manifest` — fetch the public petdex manifest.
|
||||
- :mod:`agent.pet.store` — install / list / resolve pets on disk
|
||||
(profile-aware via ``get_hermes_home()``).
|
||||
- :mod:`agent.pet.render` — decode a spritesheet and encode frames for a
|
||||
terminal (kitty / iTerm2 / sixel graphics
|
||||
protocols, with a Unicode half-block
|
||||
fallback).
|
||||
|
||||
Rendering in the Electron desktop is necessarily TypeScript (canvas), but it
|
||||
reuses the same on-disk store and the same state semantics.
|
||||
|
||||
The whole feature is a *display* concern: it adds no model tool, mutates no
|
||||
system prompt or toolset, and therefore has zero effect on prompt caching.
|
||||
"""
|
||||
|
||||
from agent.pet.constants import (
|
||||
DEFAULT_SCALE,
|
||||
FRAME_H,
|
||||
FRAME_W,
|
||||
FRAMES_PER_STATE,
|
||||
LOOP_MS,
|
||||
STATE_ROWS,
|
||||
PetState,
|
||||
)
|
||||
from agent.pet.state import derive_pet_state
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SCALE",
|
||||
"FRAME_H",
|
||||
"FRAME_W",
|
||||
"FRAMES_PER_STATE",
|
||||
"LOOP_MS",
|
||||
"STATE_ROWS",
|
||||
"PetState",
|
||||
"derive_pet_state",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Pet sprite geometry + animation-state taxonomy.
|
||||
|
||||
These values are the common petdex/Codex pet geometry. The real ``pet.json``
|
||||
usually only carries ``id``/``displayName``/``description``/``spritesheetPath``;
|
||||
row taxonomy is inferred from the atlas shape so Hermes can render both legacy
|
||||
8-row sheets and current 9-row Codex sheets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9
|
||||
# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows
|
||||
# (1728x1664). Renderers derive both row taxonomy and real column count from the
|
||||
# concrete sheet, so either shape works.
|
||||
FRAME_W = 192
|
||||
FRAME_H = 208
|
||||
|
||||
# Frames consumed per animation state (the petdex web app uses CSS
|
||||
# ``steps(6)``). A sheet may physically contain more columns; we only step
|
||||
# through the first ``FRAMES_PER_STATE``.
|
||||
FRAMES_PER_STATE = 6
|
||||
|
||||
# Full-loop duration for one state, milliseconds (petdex default).
|
||||
LOOP_MS = 1100
|
||||
|
||||
# Default on-screen scale relative to native frame size. ``display.pet.scale``
|
||||
# is the single master scalar: the desktop canvas multiplies its native pixels
|
||||
# by it and every terminal surface derives its half-block/kitty column width
|
||||
# from it (see :func:`cols_for_scale`), so one number shrinks all three
|
||||
# interfaces together. (petdex's own clients render at 0.7; we default smaller
|
||||
# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block
|
||||
# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its
|
||||
# legibility floor instead.)
|
||||
DEFAULT_SCALE = 0.33
|
||||
|
||||
# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the
|
||||
# pet clickable/visible; ceiling stops a fat-fingered value from filling the
|
||||
# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``.
|
||||
MIN_SCALE = 0.1
|
||||
MAX_SCALE = 3.0
|
||||
|
||||
|
||||
def clamp_scale(scale: float) -> float:
|
||||
"""Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point)."""
|
||||
return max(MIN_SCALE, min(MAX_SCALE, scale))
|
||||
|
||||
# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px
|
||||
# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty
|
||||
# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees.
|
||||
BASE_UNICODE_COLS = FRAME_W // 8
|
||||
|
||||
# Legibility floor for the half-block fallback. A half-block cell samples the
|
||||
# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208
|
||||
# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw
|
||||
# true pixels and have no such floor — that's why the same ``scale: 0.33`` is
|
||||
# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down
|
||||
# TO this floor (and grows it above), instead of past it into noise.
|
||||
UNICODE_MIN_COLS = 16
|
||||
|
||||
|
||||
def cols_for_scale(scale: float) -> int:
|
||||
"""Half-block width implied by *scale*, clamped to the legibility floor.
|
||||
|
||||
Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two
|
||||
renderers converge at larger sizes; below it the floor keeps the sprite
|
||||
readable rather than letting it devolve into a blob.
|
||||
"""
|
||||
return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE)))
|
||||
|
||||
|
||||
def resolve_cols(scale: float, unicode_cols: int = 0) -> int:
|
||||
"""Resolve terminal width: explicit *unicode_cols* override, else from *scale*."""
|
||||
return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale)
|
||||
|
||||
|
||||
class PetState(str, Enum):
|
||||
"""Animation state a pet can be shown in.
|
||||
|
||||
These are Hermes' activity state names. They are not always identical to the
|
||||
source atlas row names: Codex-format pets use rows like ``jumping`` /
|
||||
``running`` while the UI keeps the shorter ``jump`` / ``run`` names.
|
||||
"""
|
||||
|
||||
IDLE = "idle"
|
||||
WAVE = "wave"
|
||||
RUN = "run"
|
||||
FAILED = "failed"
|
||||
REVIEW = "review"
|
||||
JUMP = "jump"
|
||||
WAITING = "waiting"
|
||||
|
||||
|
||||
# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row,
|
||||
# 9-column atlas shape.
|
||||
LEGACY_STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
PetState.WAVE.value,
|
||||
PetState.RUN.value,
|
||||
PetState.FAILED.value,
|
||||
PetState.REVIEW.value,
|
||||
PetState.JUMP.value,
|
||||
"extra1",
|
||||
"extra2",
|
||||
]
|
||||
|
||||
# Current Petdex row order (top -> bottom) used by 1536x1872 atlases:
|
||||
# 8 columns x 9 rows of 192x208 cells.
|
||||
CODEX_STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
"running-right",
|
||||
"running-left",
|
||||
"waving",
|
||||
"jumping",
|
||||
PetState.FAILED.value,
|
||||
PetState.WAITING.value,
|
||||
"running",
|
||||
PetState.REVIEW.value,
|
||||
]
|
||||
|
||||
# Default/fallback for callers without a sheet. Prefer the current 9-row Codex
|
||||
# format because generated pets and the public Codex pet contract use it.
|
||||
STATE_ROWS: list[str] = CODEX_STATE_ROWS
|
||||
|
||||
# Canonical Hermes activity names -> accepted row-name aliases in descending
|
||||
# preference. This keeps our internal state names stable (`wave`/`jump`/`run`)
|
||||
# while matching Petdex's current `waving`/`jumping`/`running` taxonomy.
|
||||
STATE_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
PetState.IDLE.value: (PetState.IDLE.value,),
|
||||
PetState.WAVE.value: (PetState.WAVE.value, "waving"),
|
||||
PetState.JUMP.value: (PetState.JUMP.value, "jumping"),
|
||||
PetState.RUN.value: (PetState.RUN.value, "running"),
|
||||
PetState.FAILED.value: (PetState.FAILED.value,),
|
||||
PetState.REVIEW.value: (PetState.REVIEW.value,),
|
||||
PetState.WAITING.value: (PetState.WAITING.value,),
|
||||
}
|
||||
|
||||
|
||||
def state_aliases_for(state: "PetState | str") -> tuple[str, ...]:
|
||||
"""Return accepted row-name aliases for *state* (always non-empty)."""
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
aliases = STATE_ALIASES.get(value)
|
||||
return aliases if aliases else (value,)
|
||||
|
||||
|
||||
def state_rows_for_grid(row_count: int | None) -> list[str]:
|
||||
"""Return the row taxonomy for a spritesheet with *row_count* rows."""
|
||||
try:
|
||||
rows = int(row_count or 0)
|
||||
except (TypeError, ValueError):
|
||||
rows = 0
|
||||
|
||||
if rows >= len(CODEX_STATE_ROWS):
|
||||
return CODEX_STATE_ROWS
|
||||
return LEGACY_STATE_ROWS
|
||||
|
||||
|
||||
def state_row_index(state: "PetState | str", row_count: int | None = None) -> int:
|
||||
"""Return the spritesheet row index for *state* (clamped, never raises)."""
|
||||
rows = state_rows_for_grid(row_count)
|
||||
for name in state_aliases_for(state):
|
||||
try:
|
||||
return rows.index(name)
|
||||
except ValueError:
|
||||
continue
|
||||
return 0 # fall back to the idle row
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Pet generation — base-draft → hatch pipeline.
|
||||
|
||||
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
|
||||
command, and tests:
|
||||
|
||||
- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow.
|
||||
- :class:`HatchResult`, :class:`GenerationError`.
|
||||
- :mod:`atlas` — deterministic frame extraction + atlas composition/validation.
|
||||
|
||||
Image generation is delegated to the active reference-capable
|
||||
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
|
||||
atlas assembly is fully deterministic so it's testable without any API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
from agent.pet.generate.orchestrate import (
|
||||
HatchResult,
|
||||
generate_base_drafts,
|
||||
hatch_pet,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GenerationError",
|
||||
"HatchResult",
|
||||
"generate_base_drafts",
|
||||
"hatch_pet",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
"""Thin image-generation layer for pet sprites.
|
||||
|
||||
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
|
||||
two things sprite generation needs that the agent-facing ``image_generate`` tool
|
||||
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
|
||||
animation row stays the same character as the chosen base).
|
||||
|
||||
Reference grounding only works on providers that support it — currently OpenAI
|
||||
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
|
||||
those and surface a clear, actionable error otherwise rather than silently
|
||||
producing an ungrounded, drifting pet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Providers that can ground generation on a reference image, in preference order
|
||||
# (Nous Portal → OpenAI → OpenRouter → …). OpenRouter/Nous run a quality-first
|
||||
# model chain and may fall back depending on account access and endpoint behavior,
|
||||
# so fidelity can vary by configured backend + model availability.
|
||||
_REF_CAPABLE = ("nous", "openai", "openai-codex", "openrouter", "krea")
|
||||
|
||||
# Friendly display label per reference-capable provider, surfaced in the desktop
|
||||
# pet-gen picker.
|
||||
_PROVIDER_LABELS: dict[str, str] = {
|
||||
"nous": "Nous Portal",
|
||||
"openrouter": "OpenRouter",
|
||||
"openai": "OpenAI",
|
||||
"openai-codex": "OpenAI (Codex)",
|
||||
"krea": "Krea",
|
||||
}
|
||||
|
||||
|
||||
def _forced_provider_from_env() -> str | None:
|
||||
"""Optional QA override to force a pet-gen backend.
|
||||
|
||||
`HERMES_PET_IMAGE_PROVIDER=<name>` (e.g. `openrouter`) bypasses the normal
|
||||
active/default provider resolution for pet generation only. Unknown values are
|
||||
ignored so existing users are unaffected.
|
||||
"""
|
||||
forced = os.environ.get("HERMES_PET_IMAGE_PROVIDER", "").strip().lower()
|
||||
return forced if forced in _REF_CAPABLE else None
|
||||
|
||||
|
||||
class GenerationError(RuntimeError):
|
||||
"""Raised on any image-generation failure (no provider, API error, IO)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpriteProvider:
|
||||
"""Resolved provider plus whether it can take reference images."""
|
||||
|
||||
name: str
|
||||
provider: object
|
||||
supports_references: bool
|
||||
|
||||
|
||||
def _discover() -> None:
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
except Exception as exc: # noqa: BLE001 - discovery is best-effort
|
||||
logger.debug("image-gen plugin discovery failed: %s", exc)
|
||||
|
||||
|
||||
def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider:
|
||||
"""Pick the image provider to use for sprite work.
|
||||
|
||||
Preference: an explicit *prefer* choice (the desktop pet-gen picker) when it's
|
||||
reference-capable and configured, then the configured/active provider when
|
||||
it's reference-capable, else the first available reference-capable provider.
|
||||
With *require_references* off we fall back to any available provider (used for
|
||||
prompt-only base drafts).
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_active_provider, get_provider
|
||||
|
||||
# QA override: force one provider for pet-gen iteration regardless of the
|
||||
# globally active image_gen backend.
|
||||
forced = _forced_provider_from_env()
|
||||
if forced:
|
||||
chosen = get_provider(forced)
|
||||
if chosen is not None and chosen.is_available():
|
||||
return SpriteProvider(name=forced, provider=chosen, supports_references=True)
|
||||
|
||||
# An explicit user pick wins when it's reference-capable and has credentials;
|
||||
# otherwise we ignore it and fall through to the normal resolution.
|
||||
if prefer:
|
||||
chosen = get_provider(prefer)
|
||||
if prefer in _REF_CAPABLE and chosen is not None and chosen.is_available():
|
||||
return SpriteProvider(name=prefer, provider=chosen, supports_references=True)
|
||||
|
||||
# Configured / active provider first.
|
||||
active = None
|
||||
try:
|
||||
active = get_active_provider()
|
||||
except Exception: # noqa: BLE001
|
||||
active = None
|
||||
if active is not None:
|
||||
name = getattr(active, "name", "")
|
||||
if name in _REF_CAPABLE and active.is_available():
|
||||
return SpriteProvider(name=name, provider=active, supports_references=True)
|
||||
|
||||
# Any available reference-capable provider.
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is not None and provider.is_available():
|
||||
return SpriteProvider(name=name, provider=provider, supports_references=True)
|
||||
|
||||
if not require_references and active is not None and active.is_available():
|
||||
return SpriteProvider(
|
||||
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
|
||||
)
|
||||
|
||||
raise GenerationError(
|
||||
"Pet generation needs an image backend that supports reference images. "
|
||||
"Open `hermes tools` → Image Generation and configure Nous Portal, "
|
||||
"OpenRouter, or OpenAI (gpt-image-2) with an API key."
|
||||
)
|
||||
|
||||
|
||||
def list_sprite_providers() -> list[dict]:
|
||||
"""The reference-capable providers available to pick for pet generation.
|
||||
|
||||
Returns ``[{name, label, default}]`` for every ref-capable provider the user
|
||||
actually has credentials for, in preference order, marking the one
|
||||
:func:`resolve_provider` would choose with no explicit preference. Empty when
|
||||
none is configured (the picker hides itself). Best-effort: discovery hiccups
|
||||
yield an empty list.
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_provider
|
||||
|
||||
try:
|
||||
default_name = resolve_provider(require_references=True).name
|
||||
except GenerationError:
|
||||
default_name = ""
|
||||
|
||||
out: list[dict] = []
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is None or not provider.is_available():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": _PROVIDER_LABELS.get(name, name),
|
||||
"default": name == default_name,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _save_local(image_ref: str, *, prefix: str) -> Path:
|
||||
"""Return a local path for *image_ref*, downloading it if it's a URL."""
|
||||
if image_ref.startswith(("http://", "https://")):
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
return Path(save_url_image(image_ref, prefix=prefix))
|
||||
return Path(image_ref)
|
||||
|
||||
|
||||
def _rejected_background(error: str) -> bool:
|
||||
"""True when a provider error is specifically about the ``background`` param.
|
||||
|
||||
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
|
||||
reject ``background=transparent`` outright). We detect that one rejection so
|
||||
we can retry without the flag rather than failing the whole pet — our chroma
|
||||
key pass makes the result transparent regardless.
|
||||
"""
|
||||
lowered = (error or "").lower()
|
||||
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
|
||||
|
||||
|
||||
def generate(
|
||||
prompt: str,
|
||||
*,
|
||||
n: int = 1,
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
prefix: str = "pet_gen",
|
||||
aspect_ratio: str = "square",
|
||||
) -> list[Path]:
|
||||
"""Generate *n* sprite images and return their local paths.
|
||||
|
||||
*reference_images* grounds the output on a base image (required for rows).
|
||||
*aspect_ratio* picks the canvas: ``"square"`` for single-character base
|
||||
drafts, ``"landscape"`` for multi-frame row strips (the wider 1536px canvas
|
||||
gives every frame real horizontal room so winged poses don't have to be
|
||||
shrunk to avoid touching their neighbors).
|
||||
We *ask* for a transparent background, but fall back to an opaque generation
|
||||
(cleaned up downstream by the chroma-key pass) on models that reject the
|
||||
flag. Raises :class:`GenerationError` if nothing usable comes back.
|
||||
"""
|
||||
sprite = provider or resolve_provider(require_references=bool(reference_images))
|
||||
if reference_images and not sprite.supports_references:
|
||||
raise GenerationError(
|
||||
f"image backend '{sprite.name}' cannot use reference images; "
|
||||
"configure OpenAI gpt-image-2 or Krea for pet generation"
|
||||
)
|
||||
|
||||
refs = [str(p) for p in (reference_images or [])]
|
||||
|
||||
def _run(extra: dict) -> tuple[Path | None, str]:
|
||||
kwargs: dict = {"aspect_ratio": aspect_ratio, **extra}
|
||||
if refs:
|
||||
# Providers disagree on the ref kwarg name: our OpenRouter/Nous
|
||||
# backends read ``reference_images``, OpenAI's gpt-image-2 reads
|
||||
# ``reference_image_urls``. Send both; each ignores the other.
|
||||
kwargs["reference_images"] = refs
|
||||
kwargs["reference_image_urls"] = refs
|
||||
try:
|
||||
result = sprite.provider.generate(prompt, **kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - normalize provider crashes
|
||||
logger.debug("provider.generate crashed: %s", exc)
|
||||
return None, str(exc)
|
||||
if not isinstance(result, dict) or not result.get("success"):
|
||||
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
|
||||
image_ref = result.get("image")
|
||||
if not image_ref:
|
||||
return None, "provider returned no image"
|
||||
try:
|
||||
return _save_local(str(image_ref), prefix=prefix), ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, f"could not save generated image: {exc}"
|
||||
|
||||
out: list[Path] = []
|
||||
last_error = ""
|
||||
allow_transparent = True
|
||||
for _ in range(max(1, n)):
|
||||
path, err = _run({"background": "transparent"} if allow_transparent else {})
|
||||
# Model doesn't support the transparent flag → drop it for this and every
|
||||
# remaining variant (no point re-probing a capability we just disproved).
|
||||
if path is None and allow_transparent and _rejected_background(err):
|
||||
allow_transparent = False
|
||||
path, err = _run({})
|
||||
if path is not None:
|
||||
out.append(path)
|
||||
else:
|
||||
last_error = err
|
||||
|
||||
if not out:
|
||||
raise GenerationError(last_error or "image generation produced no output")
|
||||
return out
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Pet generation orchestration — the base-draft → hatch flow.
|
||||
|
||||
Two steps, mirroring the UX across every surface:
|
||||
|
||||
1. :func:`generate_base_drafts` — a handful of prompt-only "what should this pet
|
||||
look like" variants. Cheap; the user picks one (or retries for a fresh set).
|
||||
2. :func:`hatch_pet` — takes the chosen base and generates one grounded row
|
||||
strip per Hermes state, slices each into frames, composes the atlas, validates
|
||||
it, and writes the pet into the store.
|
||||
|
||||
Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row
|
||||
calls happen once, on the pet you actually keep) and gives each UI a natural
|
||||
preview/loading point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from agent.pet.generate import atlas, imagegen, prompts
|
||||
from agent.pet.generate.imagegen import GenerationError, SpriteProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
|
||||
ProgressFn = Callable[[str, str], None]
|
||||
|
||||
# Image generations are independent network calls, so we fan them out instead of
|
||||
# blocking on each in turn — a hatch is ~8 row calls that would otherwise run
|
||||
# back-to-back and routinely blow past the client's RPC timeout. Capped so we
|
||||
# don't hammer the provider's rate limit (one cold call can still be slow).
|
||||
_MAX_PARALLEL_GENERATIONS = 4
|
||||
# How many times to (re)generate a single row before accepting a best-effort
|
||||
# slice. Early attempts demand clean per-pose gutters; the last is lenient so a
|
||||
# stubborn row still yields frames instead of dropping out entirely.
|
||||
_ROW_GEN_ATTEMPTS = 3
|
||||
_MIN_FILLED_STATES = 6
|
||||
_REQUIRED_STATES = frozenset({"idle", "running-right", "waving"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HatchResult:
|
||||
"""Outcome of a successful :func:`hatch_pet`."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
spritesheet: Path
|
||||
states: list[str]
|
||||
validation: dict
|
||||
|
||||
|
||||
def _harden_transparency(path: Path) -> Path:
|
||||
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
|
||||
|
||||
``background=transparent`` is requested on every call, but image models honor
|
||||
it inconsistently — some still paint a flat (often near-white) backdrop. We
|
||||
run the same chroma-key pass the row extractor uses so every base draft the
|
||||
user picks between (and the reference the rows are grounded on) is a clean
|
||||
cutout. Best-effort: a decode failure leaves the original untouched.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(path) as opened:
|
||||
keyed = atlas.remove_background(opened.convert("RGBA"))
|
||||
# Zero the RGB of any leftover semi-transparent edge pixels so a keyed
|
||||
# draft has no colored halo when composited on the dark UI.
|
||||
keyed = atlas._clear_transparent_rgb(keyed)
|
||||
# PNG inputs are hardened in place, including mixed-case suffixes like
|
||||
# .PNG. with_suffix(".png") would name a different Path string that still
|
||||
# resolves to the same file on case-insensitive filesystems (macOS APFS,
|
||||
# Windows), and unlinking path after save would delete the hardened output.
|
||||
if path.suffix.lower() == ".png":
|
||||
out = path
|
||||
else:
|
||||
out = path.with_suffix(".png")
|
||||
keyed.save(out, format="PNG")
|
||||
if out != path:
|
||||
# The hardened PNG stands in for the draft. When the provider handed
|
||||
# back a non-PNG file (webp, jpg, gif), out is a different path, so
|
||||
# remove the original instead of leaving it behind in cache/images
|
||||
# (nothing prunes that directory outside the gateway loop).
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
|
||||
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
|
||||
return path
|
||||
|
||||
|
||||
def generate_base_drafts(
|
||||
concept: str,
|
||||
*,
|
||||
n: int = 4,
|
||||
style: str = "auto",
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
on_draft: Callable[[int, Path], None] | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> list[Path]:
|
||||
"""Generate *n* candidate base looks for *concept*; returns image paths.
|
||||
|
||||
Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`).
|
||||
Drafts are generated concurrently and *on_draft(index, path)* fires as each
|
||||
one finishes (not at the end) so callers can stream previews to the UI
|
||||
instead of leaving it blank until the whole batch is done.
|
||||
|
||||
*is_cancelled*, when supplied, is polled cooperatively: a draft that hasn't
|
||||
started yet is skipped, and once it trips we stop staging/streaming further
|
||||
drafts and cancel any queued work (already-in-flight provider calls can't be
|
||||
hard-killed, but their results are dropped).
|
||||
"""
|
||||
# A user reference image (e.g. their own pet) grounds every draft, so it
|
||||
# needs a reference-capable provider — same requirement as the row passes.
|
||||
refs = reference_images or None
|
||||
sprite = provider or imagegen.resolve_provider(require_references=bool(refs))
|
||||
cancelled = is_cancelled or (lambda: False)
|
||||
|
||||
# Each draft is its own one-shot generation, run concurrently so the user
|
||||
# waits for one image, not N. A single draft failing must not sink the set.
|
||||
# Each gets a distinct variation nudge so the options aren't near-duplicates.
|
||||
logger.info("pet generate: drafting %d base looks for %r (style=%s)", n, concept, style)
|
||||
|
||||
def _one(index: int) -> tuple[int, Path | None, str | None]:
|
||||
if cancelled():
|
||||
return index, None, None
|
||||
t0 = time.monotonic()
|
||||
variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)]
|
||||
prompt = prompts.build_base_prompt(concept, style=style, variation=variation)
|
||||
try:
|
||||
out = imagegen.generate(prompt, n=1, reference_images=refs, provider=sprite, prefix="pet_base")
|
||||
except Exception as exc: # noqa: BLE001 - tolerate a single failed draft
|
||||
logger.warning("pet generate: draft %d failed after %.1fs: %s", index, time.monotonic() - t0, exc)
|
||||
return index, None, str(exc)
|
||||
if not out:
|
||||
logger.warning("pet generate: draft %d produced no image", index)
|
||||
return index, None, "the image provider returned no image"
|
||||
logger.info("pet generate: draft %d ready in %.1fs", index, time.monotonic() - t0)
|
||||
return index, _harden_transparency(out[0]), None
|
||||
|
||||
workers = max(1, min(n, _MAX_PARALLEL_GENERATIONS))
|
||||
results: dict[int, Path] = {}
|
||||
errors: list[str] = []
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_one, i) for i in range(n)]
|
||||
# as_completed runs in *this* (the caller's) thread, so on_draft — and any
|
||||
# gateway event it emits — inherits the request's bound transport, unlike
|
||||
# the worker threads above.
|
||||
for fut in as_completed(futures):
|
||||
if cancelled():
|
||||
logger.info("pet generate: cancelled — dropping remaining drafts")
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
index, path, err = fut.result()
|
||||
if path is None:
|
||||
if err:
|
||||
errors.append(err)
|
||||
continue
|
||||
results[index] = path
|
||||
if on_draft is not None:
|
||||
try:
|
||||
on_draft(index, path)
|
||||
except Exception as exc: # noqa: BLE001 - progress is best-effort
|
||||
logger.debug("on_draft callback failed: %s", exc)
|
||||
|
||||
drafts = [results[i] for i in sorted(results)]
|
||||
if not drafts and not cancelled():
|
||||
# Surface *why* — every draft failed for a reason (a content-policy refusal
|
||||
# on a name like "minion", a provider/auth error, …); the most common one
|
||||
# is the representative cause. Far more useful than "no usable drafts".
|
||||
raise GenerationError(_drafts_failed_reason(errors))
|
||||
return drafts
|
||||
|
||||
|
||||
def _drafts_failed_reason(errors: list[str]) -> str:
|
||||
"""The representative reason a draft round produced nothing, humanized."""
|
||||
if not errors:
|
||||
return "image generation produced no usable drafts"
|
||||
from collections import Counter
|
||||
|
||||
return _humanize_image_error(Counter(errors).most_common(1)[0][0])
|
||||
|
||||
|
||||
def _humanize_image_error(error: str) -> str:
|
||||
"""Turn a raw provider error into a friendly, actionable sentence.
|
||||
|
||||
The big one is moderation: image models refuse trademarked characters and
|
||||
real people (e.g. "minion"), which reads as an opaque 400 otherwise.
|
||||
"""
|
||||
low = error.lower()
|
||||
if any(s in low for s in ("moderation_blocked", "safety system", "content policy", "content_policy")):
|
||||
return (
|
||||
"The image provider blocked this prompt — its safety filter rejects "
|
||||
"trademarked characters and real people. Try an original description."
|
||||
)
|
||||
if any(s in low for s in ("api key", "unauthorized", "401", "auth")):
|
||||
return "The image provider rejected the request — check your API key in Settings → Providers."
|
||||
if "rate limit" in low or "429" in low:
|
||||
return "The image provider is rate-limiting — wait a moment and try again."
|
||||
# Otherwise the first line, trimmed of the noisy provider envelope.
|
||||
return error.splitlines()[0].strip()[:200]
|
||||
|
||||
|
||||
def hatch_pet(
|
||||
*,
|
||||
base_image: str | Path,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
concept: str = "",
|
||||
style: str = "auto",
|
||||
on_progress: ProgressFn | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> HatchResult:
|
||||
"""Turn an approved base image into a full, installed Hermes pet.
|
||||
|
||||
Generates a grounded row strip per state, extracts frames, composes +
|
||||
validates the atlas, and registers it. The idle row falls back to the base
|
||||
look so the pet always renders. Raises :class:`GenerationError` on failure.
|
||||
|
||||
*is_cancelled*, when supplied, is polled cooperatively: rows that haven't
|
||||
started are skipped, queued rows are cancelled, and once every row is done we
|
||||
abort (raising :class:`GenerationError`) before composing/saving so a stopped
|
||||
hatch never writes a half-built pet.
|
||||
"""
|
||||
base = Path(base_image)
|
||||
if not base.is_file():
|
||||
raise GenerationError(f"base image not found: {base}")
|
||||
|
||||
sprite = provider or imagegen.resolve_provider(require_references=True)
|
||||
progress = on_progress or (lambda *_: None)
|
||||
cancelled = is_cancelled or (lambda: False)
|
||||
label = concept or display_name or slug
|
||||
|
||||
frames_by_state: dict[str, list] = {}
|
||||
total_rows = len(atlas.ROW_SPECS)
|
||||
logger.info("pet hatch %r: generating %d animation rows", slug, total_rows)
|
||||
|
||||
# Generate every state's row strip concurrently — they're independent
|
||||
# grounded calls, so the hatch waits for the slowest row, not their sum. A
|
||||
# single row failing is tolerated (idle is guaranteed below).
|
||||
def _gen_row(spec: tuple[str, int, int]) -> tuple[str, list | None]:
|
||||
state, _row, count = spec
|
||||
if cancelled():
|
||||
return state, None
|
||||
t0 = time.monotonic()
|
||||
last_exc: Exception | None = None
|
||||
# Self-healing: a model occasionally returns a row whose poses are touching
|
||||
# (no clean gutters), which slices badly. We retry such rolls; only the
|
||||
# final attempt falls back to lenient ``auto`` slicing so a stubborn row
|
||||
# still yields *something* rather than dropping the whole row.
|
||||
for attempt in range(_ROW_GEN_ATTEMPTS):
|
||||
if cancelled():
|
||||
return state, None
|
||||
strict = attempt < _ROW_GEN_ATTEMPTS - 1
|
||||
strips: list[Path] = []
|
||||
try:
|
||||
strips = imagegen.generate(
|
||||
prompts.build_row_prompt(state, count, label, style=style),
|
||||
n=1,
|
||||
reference_images=[base],
|
||||
provider=sprite,
|
||||
prefix=f"pet_row_{state}",
|
||||
# Wider canvas → each frame gets real horizontal room, so winged
|
||||
# poses keep a full, healthy size and still leave clean gutters.
|
||||
aspect_ratio="landscape",
|
||||
)
|
||||
# ``components`` requires clean per-pose gutters (raises otherwise),
|
||||
# so a touching roll is rejected and regenerated; the last attempt
|
||||
# uses ``auto`` (equal-slot fallback, never raises). Raw (fit=False)
|
||||
# so normalize_cells registers the whole pet at once.
|
||||
method = "components" if strict else "auto"
|
||||
frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False)
|
||||
logger.info(
|
||||
"pet hatch %r: row %r ready in %.1fs (attempt %d)",
|
||||
slug, state, time.monotonic() - t0, attempt + 1,
|
||||
)
|
||||
return state, frames
|
||||
except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated
|
||||
last_exc = exc
|
||||
logger.warning(
|
||||
"pet hatch %r: row %r attempt %d/%d failed: %s",
|
||||
slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc,
|
||||
)
|
||||
finally:
|
||||
# The strip is an intermediate. extract_strip_frames has already
|
||||
# decoded its frames into memory, so drop the row image after
|
||||
# every attempt (success or failure). Nothing prunes
|
||||
# cache/images outside the gateway housekeeping loop, so a CLI
|
||||
# or desktop hatch would otherwise leave each strip behind for
|
||||
# good and grow the cache without bound.
|
||||
for strip in strips:
|
||||
try:
|
||||
Path(strip).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
logger.warning(
|
||||
"pet hatch %r: row %r gave up after %.1fs: %s",
|
||||
slug, state, time.monotonic() - t0, last_exc,
|
||||
)
|
||||
return state, None
|
||||
|
||||
# running-left is derived by mirroring running-right (guaranteed-consistent
|
||||
# and one fewer generation), so we don't generate it directly.
|
||||
generated_specs = [spec for spec in atlas.ROW_SPECS if spec[0] != "running-left"]
|
||||
|
||||
workers = max(1, min(len(generated_specs), _MAX_PARALLEL_GENERATIONS))
|
||||
done = 0
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_gen_row, spec) for spec in generated_specs]
|
||||
# as_completed runs on the caller (request) thread, so progress events
|
||||
# emitted here inherit the request transport — unlike the worker threads.
|
||||
for fut in as_completed(futures):
|
||||
if cancelled():
|
||||
logger.info("pet hatch %r: cancelled — dropping remaining rows", slug)
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
state, frames = fut.result()
|
||||
done += 1
|
||||
progress("row", f"{state}:{done}:{total_rows}")
|
||||
if frames:
|
||||
frames_by_state[state] = frames
|
||||
|
||||
if cancelled():
|
||||
raise GenerationError("hatch cancelled")
|
||||
|
||||
# Derive running-left from the approved running-right row (per-frame mirror,
|
||||
# preserving order/timing). Missing running-right is rejected below; a pet
|
||||
# without its canonical walk cycle is a failed hatch, not a shippable mascot.
|
||||
right = frames_by_state.get("running-right")
|
||||
if right:
|
||||
done += 1
|
||||
progress("row", f"running-left:{done}:{total_rows}")
|
||||
frames_by_state["running-left"] = atlas.mirror_frames(right)
|
||||
logger.info("pet hatch %r: row 'running-left' mirrored from running-right", slug)
|
||||
else:
|
||||
logger.warning("pet hatch %r: no running-right to mirror; left walk left empty", slug)
|
||||
|
||||
# Idle is the resting state the renderer falls back to — guarantee it.
|
||||
if not frames_by_state.get("idle"):
|
||||
progress("row", "idle-fallback")
|
||||
frames_by_state["idle"] = [atlas.single_frame(base, fit=False)]
|
||||
|
||||
progress("compose", "")
|
||||
logger.info("pet hatch %r: composing atlas from %d states", slug, len(frames_by_state))
|
||||
# One shared scale + baseline across every state so the pet never slides or
|
||||
# pulses size between frames; compose just packs the normalized cells.
|
||||
sheet = atlas.compose_atlas(atlas.normalize_cells(frames_by_state))
|
||||
validation = atlas.validate_atlas(sheet)
|
||||
if not validation["ok"]:
|
||||
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
|
||||
filled_states = set(validation["filled_states"])
|
||||
missing_required = sorted(_REQUIRED_STATES - filled_states)
|
||||
if missing_required:
|
||||
raise GenerationError(f"missing required animation row(s): {', '.join(missing_required)}")
|
||||
if len(filled_states) < _MIN_FILLED_STATES:
|
||||
raise GenerationError(
|
||||
f"only {len(filled_states)}/{len(atlas.ROW_SPECS)} animation rows were usable; regenerate"
|
||||
)
|
||||
|
||||
from agent.pet import store
|
||||
|
||||
progress("save", slug)
|
||||
logger.info("pet hatch %r: saving pet", slug)
|
||||
pet = store.register_local_pet(
|
||||
sheet,
|
||||
slug=slug,
|
||||
display_name=display_name or slug,
|
||||
description=description,
|
||||
)
|
||||
return HatchResult(
|
||||
slug=pet.slug,
|
||||
display_name=pet.display_name,
|
||||
spritesheet=pet.spritesheet,
|
||||
states=validation["filled_states"],
|
||||
validation=validation,
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Prompt builders for pet generation.
|
||||
|
||||
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
|
||||
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
|
||||
produce one horizontal strip of N poses). Prompts stay concise and
|
||||
sprite-production oriented; the identity lock and "one transparent row" framing
|
||||
matter more than flowery description.
|
||||
|
||||
We generate the full petdex/Codex nine-state set (see
|
||||
:data:`agent.pet.generate.atlas.ROW_SPECS`) so a hatched pet is a valid
|
||||
``petdex submit`` spritesheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# What each petdex/Codex state should depict (kept short — these go straight into
|
||||
# the row prompt). Phrased to avoid the common sprite-gen failure modes (detached
|
||||
# effects, motion lines, shadows). Critical distinction: ``running`` is the
|
||||
# *working* state (in place), while ``running-right`` / ``running-left`` are the
|
||||
# actual directional walk/run cycles.
|
||||
STATE_ACTIONS: dict[str, str] = {
|
||||
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
|
||||
"running-right": (
|
||||
"a sideways walk/run locomotion cycle moving to the RIGHT: the character "
|
||||
"faces and travels right with clear directional steps, a smooth gait loop"
|
||||
),
|
||||
"running-left": (
|
||||
"a sideways walk/run locomotion cycle moving to the LEFT: the character "
|
||||
"faces and travels left with clear directional steps (the mirror of the "
|
||||
"right-facing run)"
|
||||
),
|
||||
"waving": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture",
|
||||
"jumping": "a happy celebration jump: anticipation, lift off the ground, peak, and land",
|
||||
"failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy",
|
||||
"waiting": (
|
||||
"an expectant 'waiting on you' pose: looking up/out as if asking for input "
|
||||
"or approval — distinct from idle and review"
|
||||
),
|
||||
"running": (
|
||||
"focused active work, staying IN PLACE (NOT walking or foot-running): "
|
||||
"leaning in, concentrating, busy 'thinking / processing / typing' energy"
|
||||
),
|
||||
"review": "careful inspection: a focused lean, head tilt, studying something intently",
|
||||
}
|
||||
|
||||
_STYLE_HINTS: dict[str, str] = {
|
||||
# Default to the popular petdex look: crisp 16-bit PIXEL ART, not the smooth
|
||||
# 2D illustration (let alone 3D render) gpt-image reaches for by default.
|
||||
"auto": (
|
||||
" Style: crisp 16-bit PIXEL-ART game sprite — visible square pixels, a small "
|
||||
"limited palette, clean dark outline, flat cel shading, chunky chibi "
|
||||
"proportions, like a classic SNES/JRPG party member or a petdex.dev mascot. "
|
||||
"Absolutely NOT 3D-rendered, NOT a smooth painted or vector illustration, "
|
||||
"NOT photorealistic — no soft gradients, no realistic lighting, no figurine look."
|
||||
),
|
||||
"pixel": " Render in clean 16-bit pixel-art style with visible square pixels and a limited palette.",
|
||||
"plush": " Render as a soft plush toy.",
|
||||
"clay": " Render as a claymation / soft 3D clay figure.",
|
||||
"sticker": " Render as a glossy die-cut sticker.",
|
||||
"flat-vector": " Render in flat vector mascot style.",
|
||||
"3d-toy": " Render as a glossy 3D toy.",
|
||||
"painterly": " Render in a soft painterly style.",
|
||||
}
|
||||
|
||||
_BACKGROUND = (
|
||||
"Center the character on a SINGLE flat, uniform, high-contrast chroma-key "
|
||||
"background — pure hot magenta #FF00FF (only if magenta appears on the "
|
||||
"character, use pure green #00FF00 instead). The background is ONE continuous "
|
||||
"even color that completely surrounds the character with NO gradient, "
|
||||
"vignette, texture, pattern, scenery, shadow, ground line, frame, border, "
|
||||
"panel, comic cell, gutter line, grid, or divider of any kind, so it keys out "
|
||||
"cleanly. The background color must not appear anywhere on the character. "
|
||||
"No text, no labels, no speech bubbles, no UI."
|
||||
)
|
||||
|
||||
|
||||
def style_hint(style: str | None) -> str:
|
||||
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
|
||||
|
||||
|
||||
# Row strips are generated on the wider landscape canvas (see imagegen.generate /
|
||||
# orchestrate). The extra width is what lets each pose stay a healthy size AND
|
||||
# leave a real gutter — used here only to cite concrete pixel numbers.
|
||||
_ASSUMED_STRIP_WIDTH = 1536
|
||||
|
||||
|
||||
def _spacing_spec(frame_count: int) -> tuple[int, int]:
|
||||
"""(per-pose width px, gap px) for a row of *frame_count* poses.
|
||||
|
||||
Pixel counts alone don't hold — the model fills each slot edge-to-edge with
|
||||
the full wingspan, so neighbors touch even when bodies are spaced. The lever
|
||||
that works is proportional containment on a wide canvas: give each pose its
|
||||
own equal cell and keep the ENTIRE silhouette (wings/tail/halo included)
|
||||
inside it. On the 1536px landscape strip ~70% occupancy still leaves a
|
||||
generous gutter, so the pet stays a normal, good-looking size — no shrinking.
|
||||
"""
|
||||
slots = max(1, frame_count)
|
||||
slot_w = _ASSUMED_STRIP_WIDTH / slots
|
||||
pose_px = round(slot_w * 0.7)
|
||||
gap_px = max(48, round(slot_w * 0.3))
|
||||
return pose_px, gap_px
|
||||
|
||||
|
||||
# Per-draft nudges so the 4 base options are actually distinct — gpt-image returns
|
||||
# near-duplicates for a single prompt. We vary the *look* (palette, build,
|
||||
# expression, accents), NOT the pose, so the chosen base still grounds clean,
|
||||
# consistent animation rows.
|
||||
BASE_VARIATIONS: tuple[str, ...] = (
|
||||
"",
|
||||
"a distinctly different colour palette and markings",
|
||||
"a heavier, broader silhouette with sturdier proportions",
|
||||
"a different facial structure and expression matching the concept tone, with unique accent/accessory details",
|
||||
"a leaner, taller build and an alternate colour scheme",
|
||||
"bolder, more saturated colours and a stronger expression matching the concept tone",
|
||||
)
|
||||
|
||||
|
||||
def build_base_prompt(concept: str, *, style: str | None = "auto", variation: str = "") -> str:
|
||||
"""The base look: a single, clean, centered full-body mascot.
|
||||
|
||||
*variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`).
|
||||
"""
|
||||
concept = (concept or "a distinctive mascot creature").strip()
|
||||
nudge = f" Make this design distinct: {variation}." if variation else ""
|
||||
return (
|
||||
f"A stylized mascot pet character: {concept}. "
|
||||
"Honor the requested tone and mood exactly (cute, eerie, scary, menacing, whimsical, etc.) "
|
||||
"while staying non-graphic. "
|
||||
"Compact, whole-body silhouette that reads clearly at small size, "
|
||||
"clear readable facial features, simple consistent palette. "
|
||||
# A neutral, symmetric, at-rest stance makes the cleanest identity anchor
|
||||
"Neutral front-facing standing pose, upright and symmetric, arms/limbs "
|
||||
"relaxed at the sides, feet together on the ground, any cape/accessories "
|
||||
"hanging straight and still."
|
||||
f"{nudge} "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
|
||||
|
||||
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
|
||||
"""A row strip: *frame_count* poses of the SAME character, left→right.
|
||||
|
||||
The attached base image is the identity source of truth; the prompt locks
|
||||
species, palette, face, and props to it.
|
||||
"""
|
||||
action = STATE_ACTIONS.get(state, "a simple idle pose")
|
||||
concept = (concept or "the mascot").strip()
|
||||
pose_px, gap_px = _spacing_spec(frame_count)
|
||||
return (
|
||||
f"Using the attached reference image as the exact same character "
|
||||
f"(same species, face, colors, markings, proportions, and props), "
|
||||
"preserving the same emotional tone/mood (e.g., scary stays scary, cute stays cute), "
|
||||
f"draw a single WIDE horizontal strip of {frame_count} animation frames showing {action}. "
|
||||
f"LAYOUT: arrange {frame_count} poses in ONE horizontal row at equal spacing, "
|
||||
"each pose centered in its own imaginary equal region. Draw NO panel borders, "
|
||||
"NO comic cells, NO boxes, NO vertical divider/gutter lines, NO grid, NO frame "
|
||||
"outlines between poses — the backdrop is one unbroken flat field behind all of them. "
|
||||
"Fill the WHOLE strip with the SAME single flat chroma-key color as the attached "
|
||||
"reference image's background (identical hue in every frame, no per-pose color shifts). "
|
||||
f"SPACING (critical): draw each pose at a consistent, healthy, clearly "
|
||||
f"visible size (roughly {pose_px}px wide on a {_ASSUMED_STRIP_WIDTH}px "
|
||||
f"strip) — do NOT shrink it tiny — but keep its ENTIRE silhouette "
|
||||
f"(wings, tail, halo, horns, cape, every appendage) fully INSIDE its own "
|
||||
f"cell. Leave at least {gap_px}px of empty chroma-key background between "
|
||||
f"neighboring silhouettes at their closest point (wingtip to wingtip), and "
|
||||
f"the same empty margin before the first pose and after the last. If a wing, "
|
||||
f"cape, or tail would reach into a neighbor, FOLD or angle it inward rather "
|
||||
f"than letting it cross the gap. Silhouettes must NEVER touch, overlap, "
|
||||
f"share a shadow, share a ground line, share motion trails, or merge into "
|
||||
f"one connected shape. "
|
||||
# Registration: a clean sprite sheet keeps the character locked in place
|
||||
# so only the action moves — this is what stops the loop sliding/pulsing.
|
||||
"REGISTRATION (critical): the character is the SAME height and SAME width "
|
||||
"in every frame, drawn at the SAME scale, centered over the SAME point, "
|
||||
"with all feet aligned to the SAME invisible horizontal baseline across the "
|
||||
"whole strip — this baseline is conceptual ONLY: draw NO ground line, floor, "
|
||||
"platform, horizon, or contact shadow beneath the feet. Keep the body's center, size, and stance fixed frame to "
|
||||
"frame — ONLY the limbs/features the action needs may move. Capes, cloaks, "
|
||||
"bags, and scarves stay in the SAME place and shape every frame (no "
|
||||
"swinging, flowing, or drifting) unless the action itself requires it. No "
|
||||
"pose is cropped at the strip edges. "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Fetch the public petdex manifest.
|
||||
|
||||
``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2:
|
||||
|
||||
{
|
||||
"generatedAt": "...",
|
||||
"total": 2926,
|
||||
"pets": [
|
||||
{"slug": "boba", "displayName": "Boba", "kind": "creature",
|
||||
"submittedBy": "railly",
|
||||
"spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp",
|
||||
"petJsonUrl": "https://assets.petdex.dev/.../pet.json",
|
||||
"zipUrl": "https://assets.petdex.dev/.../boba.zip"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Read-only and unauthenticated; no credentials involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MANIFEST_URL = "https://petdex.dev/api/manifest"
|
||||
|
||||
_DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
# In-process cache for the (large, slow, identical-per-call) manifest. The list
|
||||
# is a static CDN object that barely changes, yet a single session can ask for
|
||||
# it many times — every gallery open, plus a full re-fetch per install/select
|
||||
# (``find_entry``). A short TTL collapses those into one network hit without
|
||||
# going stale for long. Cleared by :func:`clear_cache` (tests).
|
||||
_MANIFEST_TTL = 300.0
|
||||
_cache: tuple[float, list[ManifestEntry]] | None = None
|
||||
|
||||
_prefetch_lock = threading.Lock()
|
||||
_prefetching = False
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop the cached manifest (forces the next fetch to hit the network)."""
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def _cache_is_warm() -> bool:
|
||||
return _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL
|
||||
|
||||
|
||||
def prefetch(*, timeout: float = _DEFAULT_TIMEOUT) -> None:
|
||||
"""Warm the manifest cache in a daemon thread — idempotent, never blocks.
|
||||
|
||||
The desktop picker calls this when it loads the (instant) local-only gallery
|
||||
so the full petdex catalog is usually cached by the time it's requested,
|
||||
without ever holding up the user's own pets on a network round-trip.
|
||||
"""
|
||||
global _prefetching
|
||||
|
||||
if _cache_is_warm():
|
||||
return
|
||||
|
||||
with _prefetch_lock:
|
||||
if _prefetching:
|
||||
return
|
||||
_prefetching = True
|
||||
|
||||
def _run() -> None:
|
||||
global _prefetching
|
||||
try:
|
||||
fetch_manifest(timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort warm
|
||||
logger.debug("petdex manifest prefetch failed: %s", exc)
|
||||
finally:
|
||||
_prefetching = False
|
||||
|
||||
threading.Thread(target=_run, name="petdex-prefetch", daemon=True).start()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestEntry:
|
||||
"""A single pet's row in the manifest."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
kind: str
|
||||
submitted_by: str
|
||||
spritesheet_url: str
|
||||
pet_json_url: str
|
||||
zip_url: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ManifestEntry":
|
||||
return cls(
|
||||
slug=str(data.get("slug", "")).strip(),
|
||||
display_name=str(data.get("displayName", "") or data.get("slug", "")),
|
||||
kind=str(data.get("kind", "") or "pet"),
|
||||
submitted_by=str(data.get("submittedBy", "") or ""),
|
||||
spritesheet_url=str(data.get("spritesheetUrl", "") or ""),
|
||||
pet_json_url=str(data.get("petJsonUrl", "") or ""),
|
||||
zip_url=str(data.get("zipUrl", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
class ManifestError(RuntimeError):
|
||||
"""Raised when the manifest can't be fetched or parsed."""
|
||||
|
||||
|
||||
def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]:
|
||||
"""Return every approved pet from the public manifest.
|
||||
|
||||
Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to
|
||||
bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on
|
||||
any network/parse failure so callers can surface a clean message.
|
||||
"""
|
||||
global _cache
|
||||
|
||||
if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL:
|
||||
return _cache[1]
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError as exc: # pragma: no cover - httpx is a core dep
|
||||
raise ManifestError("httpx is required to fetch the petdex manifest") from exc
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
MANIFEST_URL,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc
|
||||
|
||||
pets = payload.get("pets") if isinstance(payload, dict) else None
|
||||
if not isinstance(pets, list):
|
||||
raise ManifestError("petdex manifest had no 'pets' array")
|
||||
|
||||
entries: list[ManifestEntry] = []
|
||||
for raw in pets:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
entry = ManifestEntry.from_dict(raw)
|
||||
if entry.slug and entry.spritesheet_url:
|
||||
entries.append(entry)
|
||||
|
||||
_cache = (time.monotonic(), entries)
|
||||
return entries
|
||||
|
||||
|
||||
def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None:
|
||||
"""Return the manifest entry for *slug*, or ``None`` if not listed."""
|
||||
slug = slug.strip().lower()
|
||||
for entry in fetch_manifest(timeout=timeout):
|
||||
if entry.slug.lower() == slug:
|
||||
return entry
|
||||
return None
|
||||
@@ -0,0 +1,698 @@
|
||||
"""Decode a pet spritesheet and encode frames for a terminal.
|
||||
|
||||
Shared by the base CLI (writes the escape bytes to its own stdout) and the
|
||||
TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the
|
||||
decode + capability-detection + protocol-encoding logic exists exactly once.
|
||||
|
||||
Supported output modes, in fidelity order:
|
||||
|
||||
- ``kitty`` — the kitty graphics protocol (kitty, Ghostty, WezTerm).
|
||||
- ``iterm`` — iTerm2 inline images (iTerm2, WezTerm).
|
||||
- ``sixel`` — DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, …).
|
||||
- ``unicode`` — 24-bit half-block downscale; works in any truecolor terminal.
|
||||
|
||||
Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the
|
||||
spritesheet is unavailable the renderer degrades to ``unicode`` text or an
|
||||
empty string rather than raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from agent.pet.constants import (
|
||||
DEFAULT_SCALE,
|
||||
FRAME_H,
|
||||
FRAME_W,
|
||||
FRAMES_PER_STATE,
|
||||
PetState,
|
||||
state_row_index,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Public render-mode names accepted by ``display.pet.render_mode``.
|
||||
RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Terminal capability detection
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def detect_terminal_graphics() -> str:
|
||||
"""Best-effort detection of the richest graphics protocol available.
|
||||
|
||||
Env-based (non-blocking — we never issue a DA1/terminal query that could
|
||||
hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` /
|
||||
``unicode``. Conservative: unknown terminals get ``unicode``, which works
|
||||
anywhere with truecolor.
|
||||
"""
|
||||
term = os.environ.get("TERM", "").lower()
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
||||
|
||||
# The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode
|
||||
# authoritatively but does NOT scrub the terminal env vars it inherits when
|
||||
# launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …).
|
||||
# Trusting those leaks emits an image protocol the embedded xterm.js can't
|
||||
# display — you get a blank frame. Inline images there are opt-in
|
||||
# (terminal.integrated.enableImages), so default to half-blocks, which
|
||||
# always render in its truecolor grid. Users who enabled images can pin
|
||||
# display.pet.render_mode explicitly.
|
||||
if term_program == "vscode":
|
||||
return "unicode"
|
||||
|
||||
# kitty graphics protocol
|
||||
if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term:
|
||||
return "kitty"
|
||||
if term_program in {"ghostty"}:
|
||||
return "kitty"
|
||||
|
||||
# WezTerm speaks both kitty and iterm; prefer kitty (richer placement).
|
||||
if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"):
|
||||
return "kitty"
|
||||
|
||||
# iTerm2 inline images
|
||||
if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"):
|
||||
return "iterm"
|
||||
|
||||
# sixel-capable terminals (env heuristics only)
|
||||
if term_program in {"mintty"} or "foot" in term or "mlterm" in term:
|
||||
return "sixel"
|
||||
if "sixel" in term:
|
||||
return "sixel"
|
||||
|
||||
return "unicode"
|
||||
|
||||
|
||||
def supports_kitty_placeholders() -> bool:
|
||||
"""True when the terminal can paint kitty Unicode placeholders (U+10EEEE).
|
||||
|
||||
Narrower than ``detect_terminal_graphics() == "kitty"``. WezTerm speaks
|
||||
kitty APC transmits but does not implement the placeholder grid, so those
|
||||
cells render as tofu. Ghostty and kitty do. VS Code already falls out of
|
||||
``detect_terminal_graphics`` as ``unicode``.
|
||||
"""
|
||||
if detect_terminal_graphics() != "kitty":
|
||||
return False
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
||||
if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def resolve_mode(configured: str | None, *, stream=None) -> str:
|
||||
"""Resolve the effective render mode from config + the environment.
|
||||
|
||||
``configured`` is ``display.pet.render_mode`` (``auto`` → detect). Returns
|
||||
``off`` when not attached to a TTY (no point emitting graphics into a pipe
|
||||
or logfile).
|
||||
"""
|
||||
mode = (configured or "auto").strip().lower()
|
||||
if mode not in RENDER_MODES:
|
||||
mode = "auto"
|
||||
if mode == "off":
|
||||
return "off"
|
||||
|
||||
stream = stream or sys.stdout
|
||||
try:
|
||||
if not (hasattr(stream, "isatty") and stream.isatty()):
|
||||
return "off"
|
||||
except (ValueError, OSError):
|
||||
return "off"
|
||||
|
||||
if mode == "auto":
|
||||
return detect_terminal_graphics()
|
||||
return mode
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Frame decoding
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _open_sheet(path: Path):
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(path)
|
||||
return img.convert("RGBA")
|
||||
|
||||
|
||||
# Max alpha at/below which a frame counts as blank padding. petdex sheets are
|
||||
# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills
|
||||
# the trailing columns with fully transparent cells. Animating into one flashes
|
||||
# the pet blank, so we stop the row at the first such gap.
|
||||
_BLANK_ALPHA = 8
|
||||
|
||||
|
||||
def _frame_is_blank(frame) -> bool:
|
||||
"""True if *frame* has no meaningfully opaque pixel (transparent padding)."""
|
||||
return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _raw_frames(
|
||||
sheet_path: str,
|
||||
state_value: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
frames_per_state: int,
|
||||
) -> tuple:
|
||||
"""Cropped, padding-trimmed RGBA frames for one state row (unscaled).
|
||||
|
||||
Steps across the row until the first blank column so pets with ragged
|
||||
per-state frame counts never animate into empty padding. Cached; returns
|
||||
``()`` on any decode failure.
|
||||
"""
|
||||
try:
|
||||
sheet = _open_sheet(Path(sheet_path))
|
||||
cols = max(1, sheet.width // frame_w)
|
||||
rows = max(1, sheet.height // frame_h)
|
||||
row = state_row_index(state_value, rows)
|
||||
top = row * frame_h
|
||||
# Clamp the row to the sheet (some pets ship fewer rows than the 8 the
|
||||
# taxonomy reserves).
|
||||
if top + frame_h > sheet.height:
|
||||
top = max(0, sheet.height - frame_h)
|
||||
|
||||
frames = []
|
||||
for i in range(min(frames_per_state, cols)):
|
||||
left = i * frame_w
|
||||
frame = sheet.crop((left, top, left + frame_w, top + frame_h))
|
||||
if _frame_is_blank(frame):
|
||||
break # trailing transparent padding — real frames end here
|
||||
frames.append(frame)
|
||||
return tuple(frames)
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal
|
||||
logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc)
|
||||
return ()
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _frames_for(
|
||||
sheet_path: str,
|
||||
state_value: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
frames_per_state: int,
|
||||
scale_w: int,
|
||||
scale_h: int,
|
||||
):
|
||||
"""Return padding-trimmed RGBA frames for one state row, scaled.
|
||||
|
||||
Thin scaling layer over :func:`_raw_frames`; both are cached so repeated
|
||||
frame requests during animation are free.
|
||||
"""
|
||||
raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state)
|
||||
if not raw or (scale_w, scale_h) == (frame_w, frame_h):
|
||||
return list(raw)
|
||||
from PIL import Image
|
||||
|
||||
return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw]
|
||||
|
||||
|
||||
def state_frame_counts(
|
||||
sheet_path: str | Path,
|
||||
*,
|
||||
frame_w: int = FRAME_W,
|
||||
frame_h: int = FRAME_H,
|
||||
frames_per_state: int = FRAMES_PER_STATE,
|
||||
) -> dict[str, int]:
|
||||
"""Map each driven :class:`PetState` → its real (padding-trimmed) frame count.
|
||||
|
||||
The single source of truth for "how many frames does this state actually
|
||||
have?". The CLI/TUI consume the trimmed frame lists directly; the gateway
|
||||
ships this map to the desktop canvas, which steps its own loop.
|
||||
"""
|
||||
return {
|
||||
state.value: len(
|
||||
_raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state)
|
||||
)
|
||||
for state in PetState
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Encoders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _png_bytes(frame) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
frame.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _union_alpha_bbox(frames) -> tuple[int, int, int, int] | None:
|
||||
"""Union opaque-pixel bbox across *frames* (a stable trim for animation)."""
|
||||
left = top = right = bottom = None
|
||||
for frame in frames:
|
||||
try:
|
||||
bbox = frame.getchannel("A").getbbox()
|
||||
except Exception: # noqa: BLE001 - cosmetic; fail open
|
||||
bbox = None
|
||||
if not bbox:
|
||||
continue
|
||||
l, t, r, b = bbox
|
||||
left = l if left is None else min(left, l)
|
||||
top = t if top is None else min(top, t)
|
||||
right = r if right is None else max(right, r)
|
||||
bottom = b if bottom is None else max(bottom, b)
|
||||
if left is None or top is None or right is None or bottom is None:
|
||||
return None
|
||||
return (left, top, right, bottom)
|
||||
|
||||
|
||||
def _crop_frames_to_alpha_union(frames):
|
||||
"""Crop every frame to the union opaque bbox so the sprite hugs its box.
|
||||
|
||||
kitty paints the whole transmitted rectangle, transparent margins included,
|
||||
which makes the visible pet look small and adrift inside a larger cell box.
|
||||
Trimming to the visible bounds keeps the pet tight in its corner.
|
||||
"""
|
||||
bbox = _union_alpha_bbox(frames)
|
||||
if not bbox:
|
||||
return frames
|
||||
return [f.crop(bbox) for f in frames]
|
||||
|
||||
|
||||
# Nominal terminal cell size in pixels. kitty fits an image to its cell
|
||||
# rectangle preserving aspect, so a frame whose pixel size isn't a whole
|
||||
# multiple of the cell rounds up — which makes the terminal clip the bottom row
|
||||
# (the "clipped feet") and letterbox a blank row. Snapping each frame to an
|
||||
# exact cell multiple avoids that. (See ratatui-image #57: "render in multiples
|
||||
# of the font-size, to avoid stale character artifacts.")
|
||||
_CELL_W = 8
|
||||
_CELL_H = 16
|
||||
|
||||
|
||||
def _snap_frames_to_cell_grid(frames):
|
||||
"""Resize frames so width/height are exact multiples of the cell box.
|
||||
|
||||
Removes the sub-cell remainder kitty would otherwise round up + clip. All
|
||||
frames share the union-cropped size, so they snap to the same cell grid.
|
||||
"""
|
||||
if not frames:
|
||||
return frames
|
||||
from PIL import Image
|
||||
|
||||
w, h = frames[0].size
|
||||
cols = max(1, round(w / _CELL_W))
|
||||
rows = max(1, round(h / _CELL_H))
|
||||
target = (cols * _CELL_W, rows * _CELL_H)
|
||||
if (w, h) == target:
|
||||
return frames
|
||||
return [f.resize(target, Image.LANCZOS) for f in frames]
|
||||
|
||||
|
||||
def _kitty_apc(ctrl: str, data: str) -> str:
|
||||
"""Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces."""
|
||||
chunk = 4096
|
||||
if len(data) <= chunk:
|
||||
return f"\x1b_G{ctrl},m=0;{data}\x1b\\"
|
||||
out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"]
|
||||
rest = data[chunk:]
|
||||
while rest:
|
||||
piece, rest = rest[:chunk], rest[chunk:]
|
||||
out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
|
||||
"""Encode one frame via the kitty graphics protocol (transmit + display).
|
||||
|
||||
``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display
|
||||
box in terminal cells so successive frames overwrite the same area.
|
||||
"""
|
||||
ctrl = "f=100,a=T,q=2"
|
||||
if cell_cols:
|
||||
ctrl += f",c={cell_cols}"
|
||||
if cell_rows:
|
||||
ctrl += f",r={cell_rows}"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# kitty Unicode placeholders
|
||||
#
|
||||
# Ink (the TUI's React-for-terminal layer) owns the screen and measures every
|
||||
# cell's width, so it can't host raw kitty image escapes (no width to count,
|
||||
# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the
|
||||
# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the
|
||||
# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose
|
||||
# foreground color encodes the image id. Ink counts those as width-1 text, so
|
||||
# layout stays correct and the terminal paints the image underneath.
|
||||
# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_KITTY_PLACEHOLDER = "\U0010eeee"
|
||||
|
||||
# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's
|
||||
# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is
|
||||
# the diacritic that encodes the number i; we only ever need the row index.
|
||||
_ROWCOL_DIACRITICS: tuple[int, ...] = (
|
||||
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A,
|
||||
0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365,
|
||||
0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F,
|
||||
0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
|
||||
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9,
|
||||
0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
|
||||
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6,
|
||||
0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
|
||||
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736,
|
||||
0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A,
|
||||
0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817,
|
||||
0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
|
||||
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951,
|
||||
0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD,
|
||||
0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C,
|
||||
0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
|
||||
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6,
|
||||
0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
|
||||
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF,
|
||||
0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
|
||||
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0,
|
||||
0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
|
||||
0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0,
|
||||
0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
|
||||
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1,
|
||||
0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
|
||||
0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2,
|
||||
0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
|
||||
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188,
|
||||
0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
|
||||
)
|
||||
|
||||
|
||||
def kitty_image_id(slug: str) -> int:
|
||||
"""Stable per-pet image id in ``[1, 0x7FFF]``.
|
||||
|
||||
The id is encoded in the placeholder's 24-bit foreground color, so it must
|
||||
be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it
|
||||
deterministic per slug (so re-renders reuse the same terminal-side image)
|
||||
while making collisions between two different pets unlikely.
|
||||
"""
|
||||
import zlib
|
||||
|
||||
return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1
|
||||
|
||||
|
||||
def kitty_color_hex(image_id: int) -> str:
|
||||
"""Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty."""
|
||||
return "#%06x" % (image_id & 0xFFFFFF)
|
||||
|
||||
|
||||
def kitty_placeholder_rows(cols: int, rows: int) -> list[str]:
|
||||
"""Build the placeholder text grid for an *rows*×*cols* image.
|
||||
|
||||
Each line is one row of the grid: the first cell carries the row diacritic
|
||||
(column defaults to 0), and the remaining ``cols-1`` bare placeholders let
|
||||
the terminal auto-increment the column. The foreground color (the image id)
|
||||
is applied by the caller / Ink, not embedded here.
|
||||
"""
|
||||
cols = max(1, cols)
|
||||
out: list[str] = []
|
||||
for r in range(max(1, rows)):
|
||||
idx = min(r, len(_ROWCOL_DIACRITICS) - 1)
|
||||
first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx])
|
||||
out.append(first + _KITTY_PLACEHOLDER * (cols - 1))
|
||||
return out
|
||||
|
||||
|
||||
def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str:
|
||||
"""Transmit a frame as a kitty *virtual* placement for Unicode placeholders.
|
||||
|
||||
``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it
|
||||
virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the
|
||||
terminal's OK/error replies that would otherwise corrupt the host app's
|
||||
output. Re-sending with the same ``i`` replaces the image, so the static
|
||||
placeholder cells animate underneath.
|
||||
"""
|
||||
ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
|
||||
def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
|
||||
"""Encode one frame as an iTerm2 inline image (OSC 1337 File)."""
|
||||
payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
|
||||
size = len(payload)
|
||||
args = ["inline=1", f"size={size}", "preserveAspectRatio=1"]
|
||||
if cell_cols:
|
||||
args.append(f"width={cell_cols}")
|
||||
if cell_rows:
|
||||
args.append(f"height={cell_rows}")
|
||||
return f"\x1b]1337;File={';'.join(args)}:{payload}\x07"
|
||||
|
||||
|
||||
def _encode_sixel(frame) -> str:
|
||||
"""Encode one frame as DEC sixel.
|
||||
|
||||
Quantizes to an adaptive palette (≤255 colors) and emits the sixel band
|
||||
stream. Pillow has no sixel writer, so this is a compact hand-rolled
|
||||
encoder. Transparent pixels render as background (color register skipped).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
rgba = frame
|
||||
# Composite onto transparent-as-skip: track alpha to decide background.
|
||||
pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT)
|
||||
palette = pal.getpalette() or []
|
||||
px = pal.load()
|
||||
alpha = rgba.getchannel("A").load()
|
||||
w, h = pal.size
|
||||
|
||||
out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)]
|
||||
# Color register definitions (sixel uses 0..100 scale).
|
||||
used = sorted({px[x, y] for y in range(h) for x in range(w)})
|
||||
for idx in used:
|
||||
r = palette[idx * 3] if idx * 3 < len(palette) else 0
|
||||
g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0
|
||||
b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0
|
||||
out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255))
|
||||
|
||||
# Emit in 6-row bands.
|
||||
for band in range(0, h, 6):
|
||||
for color_idx in used:
|
||||
line = ["#%d" % color_idx]
|
||||
run_char = None
|
||||
run_len = 0
|
||||
|
||||
def flush():
|
||||
nonlocal run_char, run_len
|
||||
if run_char is None:
|
||||
return
|
||||
if run_len > 3:
|
||||
line.append("!%d%s" % (run_len, run_char))
|
||||
else:
|
||||
line.append(run_char * run_len)
|
||||
run_char, run_len = None, 0
|
||||
|
||||
for x in range(w):
|
||||
bits = 0
|
||||
for bit in range(6):
|
||||
y = band + bit
|
||||
if y < h and alpha[x, y] > 32 and px[x, y] == color_idx:
|
||||
bits |= 1 << bit
|
||||
ch = chr(63 + bits)
|
||||
if ch == run_char:
|
||||
run_len += 1
|
||||
else:
|
||||
flush()
|
||||
run_char, run_len = ch, 1
|
||||
flush()
|
||||
out.append("".join(line) + "$") # carriage return within band
|
||||
out.append("-") # next band
|
||||
out.append("\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
_HALF_BLOCK = "▀"
|
||||
|
||||
# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples.
|
||||
Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]]
|
||||
|
||||
|
||||
def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]:
|
||||
"""Downscale a frame to a grid of half-block cells.
|
||||
|
||||
Each cell pairs a top and bottom pixel so one terminal row encodes two
|
||||
pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` — the
|
||||
framework-neutral representation shared by the ANSI encoder (CLI) and the
|
||||
structured ``cells`` API (Ink).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
target_cols = max(4, target_cols)
|
||||
aspect = frame.height / max(1, frame.width)
|
||||
target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2)
|
||||
small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA")
|
||||
px = small.load()
|
||||
|
||||
grid: list[list[Cell]] = []
|
||||
for y in range(0, target_rows, 2):
|
||||
row: list[Cell] = []
|
||||
for x in range(target_cols):
|
||||
top = px[x, y]
|
||||
bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0)
|
||||
row.append((top, bottom))
|
||||
grid.append(row)
|
||||
return grid
|
||||
|
||||
|
||||
def _encode_unicode(frame, *, target_cols: int) -> str:
|
||||
"""Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels)."""
|
||||
lines: list[str] = []
|
||||
for row in _downscale_cells(frame, target_cols=target_cols):
|
||||
cells: list[str] = []
|
||||
for (tr, tg, tb, ta), (br, bg, bb, ba) in row:
|
||||
if ta < 32 and ba < 32:
|
||||
cells.append("\x1b[0m ") # fully transparent → blank
|
||||
continue
|
||||
cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}")
|
||||
lines.append("".join(cells) + "\x1b[0m")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Public renderer
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PetRenderer:
|
||||
"""Holds a pet's spritesheet and yields encoded frames per (state, index).
|
||||
|
||||
Construct once per pet, then call :meth:`frame` on an animation timer.
|
||||
Cheap to call repeatedly — decoded frames are cached.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spritesheet: str | Path,
|
||||
*,
|
||||
mode: str = "unicode",
|
||||
scale: float = DEFAULT_SCALE,
|
||||
unicode_cols: int = 20,
|
||||
frame_w: int = FRAME_W,
|
||||
frame_h: int = FRAME_H,
|
||||
frames_per_state: int = FRAMES_PER_STATE,
|
||||
) -> None:
|
||||
self.spritesheet = str(spritesheet)
|
||||
self.mode = mode if mode in RENDER_MODES else "unicode"
|
||||
self.scale = scale
|
||||
self.unicode_cols = unicode_cols
|
||||
self.frame_w = frame_w
|
||||
self.frame_h = frame_h
|
||||
self.frames_per_state = frames_per_state
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.mode != "off" and Path(self.spritesheet).is_file()
|
||||
|
||||
def frame_count(self, state: PetState | str) -> int:
|
||||
return len(self._frames(state))
|
||||
|
||||
def _frames(self, state: PetState | str):
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
scale_w = max(1, int(self.frame_w * self.scale))
|
||||
scale_h = max(1, int(self.frame_h * self.scale))
|
||||
return _frames_for(
|
||||
self.spritesheet,
|
||||
value,
|
||||
self.frame_w,
|
||||
self.frame_h,
|
||||
self.frames_per_state,
|
||||
scale_w,
|
||||
scale_h,
|
||||
)
|
||||
|
||||
def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]:
|
||||
"""Return one frame as a half-block cell grid (framework-neutral).
|
||||
|
||||
Used by the TUI, which renders the grid with native Ink color props
|
||||
instead of raw ANSI. Returns ``[]`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return []
|
||||
frame = frames[index % len(frames)]
|
||||
return _downscale_cells(frame, target_cols=cols or self.unicode_cols)
|
||||
|
||||
def _cell_box(self, frame) -> tuple[int, int]:
|
||||
"""Terminal cell box for a scaled frame (~8×16 px per cell).
|
||||
|
||||
Must match :meth:`frame` graphics sizing — kitty stretches the image to
|
||||
fill ``c``×``r`` cells, so these must reflect the scaled pixel
|
||||
dimensions, not a native-aspect column count (that upscales small pets).
|
||||
"""
|
||||
return max(1, frame.width // 8), max(1, frame.height // 16)
|
||||
|
||||
def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None:
|
||||
"""Build the kitty Unicode-placeholder payload for one state.
|
||||
|
||||
Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a
|
||||
list of transmit escapes (one per animation frame, all reusing
|
||||
``image_id``) and ``placeholder`` is the static text grid Ink paints.
|
||||
Placement geometry is derived from the scaled frame pixels (via
|
||||
:meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill
|
||||
``c``×``r`` cells. ``None`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return None
|
||||
frames = _crop_frames_to_alpha_union(frames)
|
||||
frames = _snap_frames_to_cell_grid(frames)
|
||||
cols, rows = self._cell_box(frames[0])
|
||||
return {
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"placeholder": kitty_placeholder_rows(cols, rows),
|
||||
"frames": [
|
||||
_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames
|
||||
],
|
||||
}
|
||||
|
||||
def frame(self, state: PetState | str, index: int) -> str:
|
||||
"""Return the encoded escape string for one frame, or ``""``.
|
||||
|
||||
``index`` is taken modulo the available frame count so callers can pass
|
||||
a free-running counter.
|
||||
"""
|
||||
if self.mode == "off":
|
||||
return ""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return ""
|
||||
frame = frames[index % len(frames)]
|
||||
cell_cols, cell_rows = self._cell_box(frame)
|
||||
|
||||
try:
|
||||
if self.mode == "kitty":
|
||||
return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows)
|
||||
if self.mode == "iterm":
|
||||
return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows)
|
||||
if self.mode == "sixel":
|
||||
return _encode_sixel(frame)
|
||||
return _encode_unicode(frame, target_cols=self.unicode_cols)
|
||||
except Exception as exc: # noqa: BLE001 - degrade silently
|
||||
logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc)
|
||||
return ""
|
||||
|
||||
|
||||
def build_renderer(
|
||||
spritesheet: str | Path,
|
||||
*,
|
||||
configured_mode: str | None = None,
|
||||
scale: float = DEFAULT_SCALE,
|
||||
unicode_cols: int = 20,
|
||||
stream=None,
|
||||
) -> PetRenderer:
|
||||
"""Convenience factory: resolve the mode from config+env, then construct."""
|
||||
mode = resolve_mode(configured_mode, stream=stream)
|
||||
return PetRenderer(
|
||||
spritesheet,
|
||||
mode=mode,
|
||||
scale=scale,
|
||||
unicode_cols=unicode_cols,
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Map agent activity → a :class:`PetState`.
|
||||
|
||||
This is the one place the "what is the agent doing right now?" → "which
|
||||
animation row?" decision lives. Each surface feeds it the signals it already
|
||||
tracks:
|
||||
|
||||
- CLI — ``KawaiiSpinner`` waiting/thinking state + tool outcomes.
|
||||
- TUI — gateway ``tool.start/complete`` + ``message.delta/complete`` events.
|
||||
- Desktop — the ``$busy``/``$awaitingResponse``/tool-event nanostores
|
||||
(re-implemented in TS, but mirroring this priority order).
|
||||
|
||||
Keeping the priority order here (and documenting it) lets the TypeScript
|
||||
mirror stay faithful without a second design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from agent.pet.constants import PetState
|
||||
|
||||
|
||||
def todos_all_done(todos: Iterable[Any] | None) -> bool:
|
||||
"""True iff there's ≥1 todo and every one is completed/cancelled.
|
||||
|
||||
The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors
|
||||
the TUI's ``isTodoDone`` so the trigger is defined once across surfaces.
|
||||
Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr.
|
||||
"""
|
||||
items = list(todos or [])
|
||||
if not items:
|
||||
return False
|
||||
|
||||
def _status(t: Any) -> Any:
|
||||
return t.get("status") if isinstance(t, dict) else getattr(t, "status", None)
|
||||
|
||||
return all(_status(t) in ("completed", "cancelled") for t in items)
|
||||
|
||||
|
||||
def derive_pet_state(
|
||||
*,
|
||||
busy: bool = False,
|
||||
awaiting_input: bool = False,
|
||||
error: bool = False,
|
||||
celebrate: bool = False,
|
||||
just_completed: bool = False,
|
||||
tool_running: bool = False,
|
||||
reasoning: bool = False,
|
||||
) -> PetState:
|
||||
"""Resolve the animation state from coarse activity signals.
|
||||
|
||||
Priority (highest first) — only one row can show at a time, so the most
|
||||
salient signal wins:
|
||||
|
||||
1. ``error`` → ``FAILED`` (a tool/turn just failed)
|
||||
2. ``celebrate`` → ``JUMP`` (explicit success beat, e.g. todos done)
|
||||
3. ``just_completed`` → ``WAVE`` (turn finished cleanly / greeting)
|
||||
4. ``awaiting_input`` → ``WAITING`` (blocked on the user — a clarify/approval
|
||||
prompt is open; this outranks the in-flight signals below because the turn
|
||||
is paused on *you*, even though a tool is technically mid-call)
|
||||
5. ``tool_running`` → ``RUN`` (a tool is executing)
|
||||
6. ``reasoning`` → ``REVIEW`` (model is thinking / reading)
|
||||
7. ``busy`` → ``RUN`` (turn in flight, unspecified work)
|
||||
8. otherwise → ``IDLE``
|
||||
"""
|
||||
if error:
|
||||
return PetState.FAILED
|
||||
if celebrate:
|
||||
return PetState.JUMP
|
||||
if just_completed:
|
||||
return PetState.WAVE
|
||||
if awaiting_input:
|
||||
return PetState.WAITING
|
||||
if tool_running:
|
||||
return PetState.RUN
|
||||
if reasoning:
|
||||
return PetState.REVIEW
|
||||
if busy:
|
||||
return PetState.RUN
|
||||
return PetState.IDLE
|
||||
@@ -0,0 +1,503 @@
|
||||
"""On-disk pet store — install / list / resolve pets.
|
||||
|
||||
Pets live under ``get_hermes_home()/pets/<slug>/`` so every profile gets its
|
||||
own set (we deliberately do **not** reuse petdex's ``~/.codex/pets`` default —
|
||||
that's owned by the petdex npm CLI and isn't profile-aware). Each installed
|
||||
pet directory holds:
|
||||
|
||||
pets/<slug>/
|
||||
pet.json # {id, displayName, description, spritesheetPath}
|
||||
spritesheet.webp # (or .png)
|
||||
|
||||
The active pet is resolved from the caller-supplied ``display.pet.slug`` config
|
||||
value (falling back to the first installed pet), so this module stays free of
|
||||
the config loader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DOWNLOAD_TIMEOUT = 60.0
|
||||
|
||||
|
||||
class PetStoreError(RuntimeError):
|
||||
"""Raised on install/IO failures."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstalledPet:
|
||||
"""A pet present on disk."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
description: str
|
||||
directory: Path
|
||||
spritesheet: Path
|
||||
created_by: str = "" # "generator" for pets hatched locally; "" for petdex installs
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self.spritesheet.is_file()
|
||||
|
||||
@property
|
||||
def generated(self) -> bool:
|
||||
return self.created_by == "generator"
|
||||
|
||||
|
||||
def pets_dir() -> Path:
|
||||
"""Return the profile-scoped pets directory (created on demand)."""
|
||||
path = get_hermes_home() / "pets"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _read_pet_json(directory: Path) -> dict:
|
||||
pet_json = directory / "pet.json"
|
||||
if not pet_json.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(pet_json.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.debug("unreadable pet.json in %s: %s", directory, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_spritesheet(directory: Path, meta: dict) -> Path:
|
||||
"""Find the spritesheet for a pet dir.
|
||||
|
||||
Honors ``spritesheetPath`` from pet.json, else probes the conventional
|
||||
filenames (``spritesheet.{webp,png}`` and petdex R2's ``sprite.webp``).
|
||||
"""
|
||||
declared = str(meta.get("spritesheetPath", "") or "").strip()
|
||||
if declared:
|
||||
candidate = directory / declared
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
for name in ("spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"):
|
||||
candidate = directory / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
# Default expectation even if missing, so callers get a stable path.
|
||||
return directory / "spritesheet.webp"
|
||||
|
||||
|
||||
def _safe_slug(slug: str) -> str:
|
||||
"""Normalize a slug to a single bare path segment.
|
||||
|
||||
Pet slugs index into ``pets_dir()/<slug>/`` for load/remove, so a value
|
||||
carrying path separators (``../``, absolute paths) could escape the pets
|
||||
directory. Strip every separator and reject ``.``/``..`` so callers can
|
||||
only ever name a direct child of the pets directory.
|
||||
"""
|
||||
segment = Path(str(slug).strip()).name
|
||||
if segment in ("", ".", ".."):
|
||||
return ""
|
||||
return segment
|
||||
|
||||
|
||||
def load_pet(slug: str) -> InstalledPet | None:
|
||||
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
return None
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return None
|
||||
meta = _read_pet_json(directory)
|
||||
return InstalledPet(
|
||||
slug=slug,
|
||||
display_name=str(meta.get("displayName", "") or slug),
|
||||
description=str(meta.get("description", "") or ""),
|
||||
directory=directory,
|
||||
spritesheet=_resolve_spritesheet(directory, meta),
|
||||
created_by=str(meta.get("createdBy", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def installed_pets() -> list[InstalledPet]:
|
||||
"""Return every installed pet (dirs containing a usable spritesheet)."""
|
||||
out: list[InstalledPet] = []
|
||||
for child in sorted(pets_dir().iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
pet = load_pet(child.name)
|
||||
if pet and pet.exists:
|
||||
out.append(pet)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None:
|
||||
"""Resolve which pet to display.
|
||||
|
||||
Precedence: the configured slug (``display.pet.slug``) if it's installed,
|
||||
otherwise the first installed pet alphabetically, otherwise ``None``.
|
||||
"""
|
||||
if configured_slug:
|
||||
pet = load_pet(configured_slug.strip())
|
||||
if pet and pet.exists:
|
||||
return pet
|
||||
pets = installed_pets()
|
||||
return pets[0] if pets else None
|
||||
|
||||
|
||||
def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet:
|
||||
"""Download *slug* from the manifest into the pets directory.
|
||||
|
||||
Idempotent: a fully-installed pet is returned as-is unless *force*. Raises
|
||||
:class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on
|
||||
failure.
|
||||
"""
|
||||
from agent.pet.manifest import find_entry
|
||||
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
raise PetStoreError("invalid pet slug")
|
||||
existing = load_pet(slug)
|
||||
if existing and existing.exists and not force:
|
||||
return existing
|
||||
|
||||
entry = find_entry(slug, timeout=timeout)
|
||||
if entry is None:
|
||||
raise PetStoreError(f"pet '{slug}' is not in the petdex manifest")
|
||||
|
||||
# Host-pin every asset URL to petdex. The manifest is trusted (HTTPS from
|
||||
# petdex.dev), but pin the asset hosts too so a compromised/spoofed manifest
|
||||
# can't redirect the download at an arbitrary host. Matches thumbnail_png.
|
||||
if not _is_petdex_host(entry.spritesheet_url):
|
||||
raise PetStoreError(f"refusing non-petdex spritesheet host for '{slug}'")
|
||||
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp"
|
||||
sprite_path = directory / f"spritesheet{sprite_ext}"
|
||||
|
||||
_download(entry.spritesheet_url, sprite_path, timeout=timeout)
|
||||
|
||||
# Fetch the upstream pet.json if present; otherwise synthesize a minimal
|
||||
# one so the local layout is self-describing.
|
||||
meta: dict = {}
|
||||
if entry.pet_json_url and _is_petdex_host(entry.pet_json_url):
|
||||
try:
|
||||
meta = _download_json(entry.pet_json_url, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - non-fatal, fall back below
|
||||
logger.debug("pet.json fetch failed for %s: %s", slug, exc)
|
||||
if not isinstance(meta, dict) or not meta:
|
||||
meta = {"id": slug, "displayName": entry.display_name, "description": ""}
|
||||
meta["spritesheetPath"] = sprite_path.name
|
||||
meta.setdefault("id", slug)
|
||||
meta.setdefault("displayName", entry.display_name)
|
||||
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
pet = load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
raise PetStoreError(f"install of '{slug}' did not produce a spritesheet")
|
||||
return pet
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
|
||||
return slug or "pet"
|
||||
|
||||
|
||||
def unique_slug(name: str) -> str:
|
||||
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
|
||||
base = slugify(name)
|
||||
slug = base
|
||||
counter = 2
|
||||
while (pets_dir() / slug).exists():
|
||||
slug = f"{base}-{counter}"
|
||||
counter += 1
|
||||
return slug
|
||||
|
||||
|
||||
def _write_spritesheet(source, dest: Path) -> None:
|
||||
"""Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*."""
|
||||
if isinstance(source, (bytes, bytearray)):
|
||||
dest.write_bytes(bytes(source))
|
||||
return
|
||||
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(source, (str, Path)):
|
||||
with Image.open(source) as opened:
|
||||
image = opened.convert("RGBA")
|
||||
else:
|
||||
image = source.convert("RGBA")
|
||||
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
|
||||
|
||||
def register_local_pet(
|
||||
spritesheet,
|
||||
*,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
) -> InstalledPet:
|
||||
"""Write a locally-generated pet into the store and return it.
|
||||
|
||||
*spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet
|
||||
appears in :func:`installed_pets` immediately, and because :func:`install_pet`
|
||||
returns an already-on-disk pet before consulting the manifest, it can be
|
||||
adopted (``pet.select`` / ``/pet <slug>``) without a manifest entry.
|
||||
"""
|
||||
slug = slugify(slug)
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
sprite_path = directory / "spritesheet.webp"
|
||||
try:
|
||||
_write_spritesheet(spritesheet, sprite_path)
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
|
||||
|
||||
meta = {
|
||||
"id": slug,
|
||||
"displayName": display_name or slug,
|
||||
"description": description or "",
|
||||
"spritesheetPath": sprite_path.name,
|
||||
"createdBy": "generator",
|
||||
}
|
||||
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
pet = load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
raise PetStoreError(f"register of generated pet '{slug}' did not produce a spritesheet")
|
||||
return pet
|
||||
|
||||
|
||||
def export_pet(slug: str) -> tuple[str, bytes]:
|
||||
"""Zip an installed pet's folder (pet.json + spritesheet) → (filename, bytes).
|
||||
|
||||
Dotfiles (cached thumbs, backups) are skipped so the archive is a clean,
|
||||
re-importable pet package. Raises :class:`PetStoreError` if not installed.
|
||||
"""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
root = pets_dir()
|
||||
directory = root / slug.strip()
|
||||
# Guard against traversal: the target must be a direct child of pets_dir.
|
||||
if directory.resolve().parent != root.resolve() or not directory.is_dir():
|
||||
raise PetStoreError(f"pet '{slug}' is not installed")
|
||||
|
||||
name = directory.name
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_file() and not path.name.startswith("."):
|
||||
archive.write(path, f"{name}/{path.name}")
|
||||
return f"{name}.zip", buf.getvalue()
|
||||
|
||||
|
||||
_THUMB_FRAME_W = 192
|
||||
_THUMB_FRAME_H = 208
|
||||
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
|
||||
|
||||
|
||||
def _thumbs_dir() -> Path:
|
||||
path = pets_dir() / ".thumbs"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _is_petdex_host(url: str) -> bool:
|
||||
"""True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
except ValueError:
|
||||
return False
|
||||
return host == "petdex.dev" or host.endswith(".petdex.dev")
|
||||
|
||||
|
||||
def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None:
|
||||
"""Return a small idle-frame PNG for *slug*, cached on disk.
|
||||
|
||||
Crops the top-left (idle, frame 0) cell of the spritesheet and downsamples
|
||||
it to a thumbnail. Source preference: an installed spritesheet on disk, else
|
||||
*source_url* — but only when it points at petdex (so the gateway never
|
||||
fetches an arbitrary client-supplied URL). Returns ``None`` when there's no
|
||||
usable source or Pillow/network fails; callers render a placeholder.
|
||||
|
||||
Doing this server-side sidesteps the renderer's CSP / R2 hotlink limits that
|
||||
break a direct ``<img src=cdn>`` and lets the result ride the authenticated
|
||||
gateway as a same-origin data URL.
|
||||
"""
|
||||
slug = slug.strip()
|
||||
if not slug:
|
||||
return None
|
||||
|
||||
cache = _thumbs_dir() / f"{slug}.png"
|
||||
if cache.is_file():
|
||||
try:
|
||||
return cache.read_bytes()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
sheet_bytes: bytes | None = None
|
||||
pet = load_pet(slug)
|
||||
if pet and pet.exists:
|
||||
try:
|
||||
sheet_bytes = pet.spritesheet.read_bytes()
|
||||
except OSError:
|
||||
sheet_bytes = None
|
||||
|
||||
if sheet_bytes is None and source_url and _is_petdex_host(source_url):
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(
|
||||
source_url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
sheet_bytes = resp.content
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder
|
||||
logger.debug("thumb fetch failed for %s: %s", slug, exc)
|
||||
|
||||
if not sheet_bytes:
|
||||
return None
|
||||
|
||||
try:
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(sheet_bytes)) as im:
|
||||
frame = im.convert("RGBA").crop(
|
||||
(0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height))
|
||||
)
|
||||
height = round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W)
|
||||
frame = frame.resize((_THUMB_W, height), Image.NEAREST)
|
||||
buf = io.BytesIO()
|
||||
frame.save(buf, format="PNG")
|
||||
data = buf.getvalue()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("thumb crop failed for %s: %s", slug, exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
cache.write_bytes(data)
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def remove_pet(slug: str) -> bool:
|
||||
"""Delete an installed pet directory. Returns True if anything was removed."""
|
||||
import shutil
|
||||
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
return False
|
||||
|
||||
# The cached thumbnail lives in pets/.thumbs/<slug>.png — OUTSIDE the pet
|
||||
# dir, so rmtree won't catch it. Drop it too, or a later pet that reuses this
|
||||
# slug renders this one's stale thumbnail.
|
||||
try:
|
||||
(_thumbs_dir() / f"{slug}.png").unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return False
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
return not directory.exists()
|
||||
|
||||
|
||||
def rename_pet(slug: str, display_name: str) -> str | None:
|
||||
"""Rename a pet's ``displayName`` AND realign its slug/dir to match.
|
||||
|
||||
Generated pets are hatched under a provisional, prompt-derived slug; when
|
||||
the user names the pet on the reveal screen we make that name the real
|
||||
identity so lists/subtitles show what they typed, not the prompt. The dir is
|
||||
renamed to ``slugify(name)`` (and the cached thumbnail moved alongside it)
|
||||
whenever that yields a free, different slug — otherwise the slug is left as
|
||||
is. Returns the resulting slug on success, or ``None`` on failure.
|
||||
"""
|
||||
slug = _safe_slug(slug)
|
||||
display_name = (display_name or "").strip()
|
||||
if not slug or not display_name:
|
||||
return None
|
||||
directory = pets_dir() / slug
|
||||
pet_json = directory / "pet.json"
|
||||
if not pet_json.is_file():
|
||||
return None
|
||||
try:
|
||||
meta = json.loads(pet_json.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
meta["displayName"] = display_name
|
||||
|
||||
new_slug = slug
|
||||
desired = slugify(display_name)
|
||||
if desired and desired != slug and not (pets_dir() / desired).exists():
|
||||
try:
|
||||
directory.rename(pets_dir() / desired)
|
||||
try:
|
||||
(_thumbs_dir() / f"{slug}.png").rename(_thumbs_dir() / f"{desired}.png")
|
||||
except OSError:
|
||||
pass
|
||||
directory = pets_dir() / desired
|
||||
pet_json = directory / "pet.json"
|
||||
new_slug = desired
|
||||
meta["id"] = new_slug
|
||||
except OSError:
|
||||
new_slug = slug # keep the provisional slug if the move fails
|
||||
|
||||
try:
|
||||
pet_json.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
return new_slug
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, timeout: float) -> None:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
with httpx.stream(
|
||||
"GET",
|
||||
url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
with tmp.open("wb") as fh:
|
||||
for chunk in resp.iter_bytes():
|
||||
fh.write(chunk)
|
||||
tmp.replace(dest)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise PetStoreError(f"download failed for {url}: {exc}") from exc
|
||||
|
||||
|
||||
def _download_json(url: str, *, timeout: float) -> dict:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(
|
||||
url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
Reference in New Issue
Block a user