niahere 0.2.88 → 0.2.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.88",
3
+ "version": "0.2.90",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -4,7 +4,7 @@ description: >
4
4
  Persistent task management via Beads CLI (bd). Use when user mentions tasks, todos, issues, or tracking work.
5
5
  Check `which bd` first — if missing, offer: `npm install -g @beads/bd`.
6
6
  All commands: run from `$BEATS_DIR` (for example `~/.niahere/beads`) and use `bd <command>`. Always label: `--label project:<project-name>`.
7
- Run `bd help-all` for available commands. Not for ephemeral in-conversation tracking.
7
+ Run `bd --help` or `bd help --all` for available commands. Not for ephemeral in-conversation tracking.
8
8
  ---
9
9
 
10
10
  ## Overview
@@ -19,7 +19,7 @@ Global task manager powered by [Beads](https://github.com/steveyegge/beads). Sto
19
19
  3. Set `BEATS_DIR` to your Beads workspace (for example `~/.niahere/beads`).
20
20
  4. All commands: `cd "$BEATS_DIR" && bd <command>`.
21
21
  5. Always label with `--label project:<name>`.
22
- 6. Run `cd "$BEATS_DIR" && bd help-all` for available commands.
22
+ 6. Run `cd "$BEATS_DIR" && bd --help` or `bd help --all` for available commands.
23
23
 
24
24
  ## Core Commands
25
25
 
@@ -53,6 +53,8 @@ bd update <id> --status in_progress # Start work
53
53
  bd update <id> --description "..." # Add/replace description
54
54
  bd update <id> --add-label personal # Add label
55
55
  bd update <id> --set-labels bug,urgent # Replace all labels
56
+ bd update <id> --claim # Atomically claim work
57
+ bd update <id> --set-metadata team=platform # Set task-scoped metadata
56
58
  ```
57
59
 
58
60
  Chain multiple updates: `bd update <id> --priority P1 --type bug --parent <parent-id>`
@@ -63,7 +65,11 @@ Chain multiple updates: `bd update <id> --priority P1 --type bug --parent <paren
63
65
  bd list # Open tasks (tree view)
64
66
  bd list --all # Include closed/deferred tasks
65
67
  bd list --label project:<name> # Filter by project
68
+ bd ready # Ready work with blocker-aware semantics
69
+ bd ready --claim # Atomically claim the first matching ready issue
66
70
  bd show <id> # Full details of a task
71
+ bd show <id> --long # Full details, including extended metadata
72
+ bd show --current --long # Current/last touched issue with metadata
67
73
  bd children <id> # List children of a parent
68
74
  ```
69
75
 
@@ -88,6 +94,57 @@ bd reopen <id> # Reopen if closed prematurely
88
94
  - Ephemeral/conversation-only tracking → use conversation context, not beads
89
95
  - `bd set-state ... state=...` is for operational metadata only; it does not change the task status shown in list.
90
96
 
97
+ ## Agent Session Tracking
98
+
99
+ When a Beads task is worked in Claude Code, Codex, or another agent CLI, store the active session on the task as metadata. Do not invent session IDs yourself; let the tool create the session, then attach the discovered session ID to the bead.
100
+
101
+ Use one shared metadata schema for all tools:
102
+
103
+ ```bash
104
+ session_tool=codex|claude
105
+ session_id=<tool-created-session-id>
106
+ session_cwd=<absolute repo/worktree path>
107
+ session_resume_cmd=<exact resume command>
108
+ session_attached_at=<ISO timestamp>
109
+ ```
110
+
111
+ Attach a session after starting or identifying it:
112
+
113
+ ```bash
114
+ cd "$BEATS_DIR"
115
+ bd update <id> --claim
116
+ bd update <id> \
117
+ --set-metadata session_tool=codex \
118
+ --set-metadata session_id="$sid" \
119
+ --set-metadata session_cwd="$PWD" \
120
+ --set-metadata session_resume_cmd="cd $PWD && codex resume $sid" \
121
+ --set-metadata session_attached_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
122
+ bd note <id> "Attached codex session $sid"
123
+ ```
124
+
125
+ For Claude, use the same keys and a Claude resume command:
126
+
127
+ ```bash
128
+ bd update <id> \
129
+ --set-metadata session_tool=claude \
130
+ --set-metadata session_id="$sid" \
131
+ --set-metadata session_cwd="$PWD" \
132
+ --set-metadata session_resume_cmd="cd $PWD && claude --resume $sid" \
133
+ --set-metadata session_attached_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
134
+ ```
135
+
136
+ Find session-backed tasks:
137
+
138
+ ```bash
139
+ bd list --has-metadata-key session_id --all --long
140
+ bd list --metadata-field session_tool=codex --all --long
141
+ bd list --metadata-field session_tool=claude --all --long
142
+ bd show <id> --long
143
+ bd show --current --long
144
+ ```
145
+
146
+ Use task metadata as the source of truth. `bd kv` is global and not task-scoped, so do not use it for task sessions. `bd audit` is append-only history, not a jump table. Notes/comments are useful human breadcrumbs, but the resume command and session ID should live in metadata.
147
+
91
148
  ## Hierarchy & Organization
92
149
 
93
150
  ### When to use parent-child vs labels
@@ -14,12 +14,14 @@ General-purpose image generation skill supporting **OpenAI** (default) and **Gem
14
14
  ## Setup
15
15
 
16
16
  API keys in `~/.niahere/config.yaml`:
17
+
17
18
  ```yaml
18
19
  openai_api_key: sk-...
19
20
  gemini_api_key: AIza...
20
21
  ```
21
22
 
22
23
  Or set via CLI or environment variables:
24
+
23
25
  ```bash
24
26
  nia config set openai_api_key sk-...
25
27
  nia config set gemini_api_key AIza...
@@ -29,25 +31,39 @@ Keys are resolved in order: `--api-key` flag > env var (`$OPENAI_API_KEY` / `$GE
29
31
 
30
32
  ## Providers & Models
31
33
 
32
- | Provider | Default Model | Alternatives |
33
- |----------|--------------|--------------|
34
- | **OpenAI** (default) | `gpt-image-1.5` | `gpt-image-1`, `gpt-image-1-mini` |
35
- | **Gemini** | `gemini-3.1-flash-image-preview` | `gemini-3-pro-image-preview`, `gemini-2.5-flash-image` |
34
+ | Provider | Default Model | Alternatives |
35
+ | -------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------- |
36
+ | **OpenAI** (default) | `gpt-image-2` | `gpt-image-1.5`, `gpt-image-1-mini` |
37
+ | **Gemini** | `gemini-3.1-flash-image-preview` (Nano Banana 2) | `gemini-3-pro-image-preview` (Nano Banana Pro), `gemini-2.5-flash-image` (GA) |
38
+
39
+ `gpt-image-2` (Apr 2026) is the current OpenAI flagship — native reasoning, up to 16 reference images, native 2K. `dall-e-2`/`dall-e-3` were shut off on the API on May 12, 2026.
36
40
 
37
- Note: `dall-e-2` and `dall-e-3` are deprecated (EOL May 2026). Use `gpt-image-1.5` instead.
41
+ ## Per-image pricing (May 2026)
42
+
43
+ | Model | Price |
44
+ | -------------------------------- | ------------------------------------------------------------------------------------ |
45
+ | `gpt-image-2` | ~$0.006 (low) → $0.053 (medium 1024) → $0.35 (high 4K). Token-based; Batch API −50%. |
46
+ | `gpt-image-1.5` | ~20% cheaper than gpt-image-1 across tiers |
47
+ | `gpt-image-1` | low $0.011, medium $0.042, high $0.167 (1024²) |
48
+ | `gemini-3-pro-image-preview` | $0.134 (1K/2K), $0.24 (4K) |
49
+ | `gemini-3.1-flash-image-preview` | $0.045 (512), $0.067 (1K), $0.101 (2K), $0.151 (4K) |
50
+ | `gemini-2.5-flash-image` | $0.039 standard, $0.0195 batch |
38
51
 
39
52
  ## Quick Reference
40
53
 
41
54
  ```bash
42
- SCRIPT="/Users/aman/.shared/skills/image-generation/scripts/generate_image.py"
55
+ SCRIPT="$(dirname "$0")/scripts/generate_image.py" # or the absolute path under your skills dir
43
56
 
44
- # Basic generation (OpenAI, default)
57
+ # Basic generation (OpenAI gpt-image-2, default)
45
58
  python3 $SCRIPT --prompt "A sunset over mountains"
46
59
 
47
60
  # High quality
48
61
  python3 $SCRIPT --prompt "Oil painting of a forest" --quality high
49
62
 
50
- # With aspect ratio
63
+ # 2K output (OpenAI gpt-image-2; Gemini 2K via --resolution)
64
+ python3 $SCRIPT --prompt "Detailed cityscape" --resolution 2K
65
+
66
+ # Aspect ratio
51
67
  python3 $SCRIPT --prompt "Portrait photo" --aspect-ratio 3:4
52
68
 
53
69
  # With reference image (OpenAI edit mode)
@@ -56,6 +72,10 @@ python3 $SCRIPT --prompt "Add a rainbow to this scene" --reference photo.png
56
72
  # Gemini provider
57
73
  python3 $SCRIPT --provider gemini --prompt "Watercolor sunset" --aspect-ratio 16:9
58
74
 
75
+ # Gemini 4K (use Pro — 3.1 Flash currently ignores 2K/4K and returns ~1K)
76
+ python3 $SCRIPT --provider gemini --model gemini-3-pro-image-preview \
77
+ --prompt "Cinematic landscape" --resolution 4K --aspect-ratio 16:9
78
+
59
79
  # Gemini with reference
60
80
  python3 $SCRIPT --provider gemini --reference face.png \
61
81
  --prompt "Same person sitting in a cafe, natural lighting" --aspect-ratio 9:16
@@ -66,17 +86,24 @@ python3 $SCRIPT --prompt "A cat" --output /path/to/output/
66
86
 
67
87
  ## Aspect Ratios
68
88
 
69
- | Use Case | Ratio | Notes |
70
- |----------|-------|-------|
71
- | Square / social | `1:1` | Default |
72
- | Portrait | `3:4` or `2:3` | Vertical |
73
- | Landscape | `4:3` or `16:9` | Wide |
74
- | Phone / story | `9:16` | Vertical tall |
75
- | Ultrawide | `21:9` | Cinematic |
89
+ | Use Case | Ratio | Notes |
90
+ | --------------- | --------------- | ------------- |
91
+ | Square / social | `1:1` | Default |
92
+ | Portrait | `3:4` or `2:3` | Vertical |
93
+ | Landscape | `4:3` or `16:9` | Wide |
94
+ | Phone / story | `9:16` | Vertical tall |
95
+ | Ultrawide | `21:9` | Cinematic |
96
+
97
+ OpenAI maps ratios to closest supported size. At `--resolution 1K` (default): `1024x1024`, `1024x1536`, `1536x1024`. At `--resolution 2K`: `2048x2048`, `1536x2048`, `2048x1536` (gpt-image-2 only; dims must be multiples of 16, max ~2048).
98
+
99
+ ## Resolution (`--resolution`)
76
100
 
77
- OpenAI maps ratios to closest supported size (`1024x1024`, `1024x1536`, `1536x1024`).
101
+ - OpenAI: `1K` (default) or `2K` (gpt-image-2 only).
102
+ - Gemini: `1K` (default), `2K`, or `4K`. **Caveat:** `gemini-3.1-flash-image-preview` currently ignores 2K/4K and returns ~1K — use `gemini-3-pro-image-preview` for true 2K/4K.
78
103
 
79
- ## OpenAI Quality (gpt-image-1 only)
104
+ ## OpenAI Quality (`--quality`)
105
+
106
+ Applies to all `gpt-image-*` models.
80
107
 
81
108
  - `auto` (default) — let the model decide
82
109
  - `high` — best quality, slower
@@ -100,6 +127,7 @@ For photorealistic results, use structured JSON prompts covering separate concer
100
127
  ```
101
128
 
102
129
  Key principles:
130
+
103
131
  1. **Separate concerns** — one aspect per block
104
132
  2. **Specify camera** — lens mm and aperture drive realism
105
133
  3. **Light direction** — "soft light from upper right" > "good lighting"
@@ -108,13 +136,14 @@ Key principles:
108
136
 
109
137
  ## Provider Selection Guide
110
138
 
111
- | Need | Use |
112
- |------|-----|
113
- | General image gen, highest quality | OpenAI `gpt-image-1.5` |
114
- | Budget-friendly | OpenAI `gpt-image-1-mini` |
115
- | Reference-based identity (same face) | Gemini (better at preserving identity from reference) |
116
- | Image editing / inpainting | OpenAI edit mode (`--reference`) |
117
- | Free tier / no OpenAI key | Gemini |
139
+ | Need | Use |
140
+ | ------------------------------------ | ------------------------------------------------------------ |
141
+ | General image gen, highest quality | OpenAI `gpt-image-2` |
142
+ | Budget-friendly | OpenAI `gpt-image-1-mini` or Gemini `gemini-2.5-flash-image` |
143
+ | Reference-based identity (same face) | Gemini (better at preserving identity from reference) |
144
+ | Image editing / inpainting | OpenAI edit mode (`--reference`) |
145
+ | 4K output | Gemini `gemini-3-pro-image-preview` (`--resolution 4K`) |
146
+ | Free tier / no OpenAI key | Gemini |
118
147
 
119
148
  ## Combining with Bella
120
149
 
@@ -3,21 +3,25 @@
3
3
  General-purpose image generation using OpenAI (default) or Gemini.
4
4
 
5
5
  Supports:
6
- - OpenAI: gpt-image-1.5 (default), gpt-image-1, gpt-image-1-mini
6
+ - OpenAI: gpt-image-2 (default), gpt-image-1.5, gpt-image-1-mini
7
7
  - Gemini: gemini-3.1-flash-image-preview (default), gemini-3-pro-image-preview, gemini-2.5-flash-image
8
8
 
9
9
  Usage:
10
10
  # OpenAI (default)
11
11
  python3 generate_image.py --prompt "A sunset over mountains"
12
12
 
13
+ # OpenAI 2K output (gpt-image-2 only)
14
+ python3 generate_image.py --prompt "Detailed cityscape" --resolution 2K
15
+
13
16
  # OpenAI with reference image (edit mode)
14
17
  python3 generate_image.py --prompt "Add a hot air balloon" --reference photo.png
15
18
 
16
19
  # Gemini
17
20
  python3 generate_image.py --provider gemini --prompt "A sunset over mountains"
18
21
 
19
- # Gemini with reference image
20
- python3 generate_image.py --provider gemini --prompt "Same person in a cafe" --reference face.png
22
+ # Gemini Pro at 4K
23
+ python3 generate_image.py --provider gemini --model gemini-3-pro-image-preview \\
24
+ --prompt "Cinematic landscape" --resolution 4K --aspect-ratio 16:9
21
25
  """
22
26
 
23
27
  from __future__ import annotations
@@ -40,17 +44,26 @@ NIA_CONFIG = NIA_HOME / "config.yaml"
40
44
  TIMESTAMP_FORMAT = "%Y%m%d_%H%M%S"
41
45
 
42
46
  # --- Provider defaults ---
43
- OPENAI_DEFAULT_MODEL = "gpt-image-1.5"
47
+ OPENAI_DEFAULT_MODEL = "gpt-image-2"
44
48
  GEMINI_DEFAULT_MODEL = "gemini-3.1-flash-image-preview"
45
49
 
46
50
  DEFAULT_ASPECT_RATIO = "1:1"
47
51
  ALLOWED_ASPECT_RATIOS = (
48
- "1:1", "3:4", "4:3", "9:16", "16:9",
49
- "2:3", "3:2", "4:5", "5:4", "21:9",
52
+ "1:1",
53
+ "3:4",
54
+ "4:3",
55
+ "9:16",
56
+ "16:9",
57
+ "2:3",
58
+ "3:2",
59
+ "4:5",
60
+ "5:4",
61
+ "21:9",
50
62
  )
51
63
 
52
- # OpenAI size mappings (closest match for aspect ratio)
53
- OPENAI_SIZE_MAP = {
64
+ # OpenAI size mappings per resolution (closest match for aspect ratio).
65
+ # 2K caps at 2048; dims must be multiples of 16. gpt-image-2 only.
66
+ OPENAI_SIZE_MAP_1K = {
54
67
  "1:1": "1024x1024",
55
68
  "3:4": "1024x1536",
56
69
  "4:3": "1536x1024",
@@ -62,10 +75,25 @@ OPENAI_SIZE_MAP = {
62
75
  "5:4": "1536x1024",
63
76
  "21:9": "1536x1024",
64
77
  }
78
+ OPENAI_SIZE_MAP_2K = {
79
+ "1:1": "2048x2048",
80
+ "3:4": "1536x2048",
81
+ "4:3": "2048x1536",
82
+ "9:16": "1536x2048",
83
+ "16:9": "2048x1536",
84
+ "2:3": "1536x2048",
85
+ "3:2": "2048x1536",
86
+ "4:5": "1536x2048",
87
+ "5:4": "2048x1536",
88
+ "21:9": "2048x1536",
89
+ }
65
90
 
66
- # OpenAI quality options
91
+ # OpenAI quality options (applies to all gpt-image-* models)
67
92
  OPENAI_QUALITIES = ("auto", "high", "medium", "low")
68
93
 
94
+ # Resolution choices. OpenAI: 1K|2K (2K requires gpt-image-2). Gemini: 1K|2K|4K.
95
+ ALLOWED_RESOLUTIONS = ("1K", "2K", "4K")
96
+
69
97
 
70
98
  def safe_mime(path: str) -> str:
71
99
  mime, _ = mimetypes.guess_type(path)
@@ -92,6 +120,7 @@ def read_config_key(key: str) -> str:
92
120
  return ""
93
121
  try:
94
122
  import importlib
123
+
95
124
  yaml = importlib.import_module("yaml")
96
125
  with NIA_CONFIG.open("r") as f:
97
126
  config = yaml.safe_load(f)
@@ -110,10 +139,7 @@ def resolve_api_key(provider: str, cli_key: str | None) -> str:
110
139
  return cli_key
111
140
 
112
141
  if provider == "openai":
113
- return (
114
- os.environ.get("OPENAI_API_KEY", "")
115
- or read_config_key("openai_api_key")
116
- )
142
+ return os.environ.get("OPENAI_API_KEY", "") or read_config_key("openai_api_key")
117
143
  else:
118
144
  return (
119
145
  os.environ.get("GEMINI_API_KEY", "")
@@ -124,6 +150,7 @@ def resolve_api_key(provider: str, cli_key: str | None) -> str:
124
150
 
125
151
  # --- OpenAI Generation ---
126
152
 
153
+
127
154
  def generate_openai(
128
155
  api_key: str,
129
156
  prompt: str,
@@ -139,18 +166,15 @@ def generate_openai(
139
166
  return _openai_generate(api_key, prompt, model, size, quality, n)
140
167
 
141
168
 
142
- def _openai_generate(
143
- api_key: str, prompt: str, model: str, size: str, quality: str, n: int
144
- ) -> tuple[bytes, str]:
169
+ def _openai_generate(api_key: str, prompt: str, model: str, size: str, quality: str, n: int) -> tuple[bytes, str]:
145
170
  url = "https://api.openai.com/v1/images/generations"
146
171
  payload: dict = {
147
172
  "model": model,
148
173
  "prompt": prompt,
149
174
  "n": n,
150
175
  "size": size,
151
- "response_format": "b64_json",
152
176
  }
153
- if model == "gpt-image-1":
177
+ if model.startswith("gpt-image-"):
154
178
  payload["quality"] = quality
155
179
 
156
180
  req = urllib.request.Request(
@@ -166,8 +190,7 @@ def _openai_generate(
166
190
 
167
191
 
168
192
  def _openai_edit(
169
- api_key: str, prompt: str, reference_path: str, model: str, size: str,
170
- quality: str, n: int
193
+ api_key: str, prompt: str, reference_path: str, model: str, size: str, quality: str, n: int
171
194
  ) -> tuple[bytes, str]:
172
195
  """Use OpenAI images/edits endpoint with a reference image."""
173
196
  import io
@@ -184,9 +207,7 @@ def _openai_edit(
184
207
  filename = Path(filepath).name
185
208
  mime = safe_mime(filepath)
186
209
  body.write(f"--{boundary}\r\n".encode())
187
- body.write(
188
- f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode()
189
- )
210
+ body.write(f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode())
190
211
  body.write(f"Content-Type: {mime}\r\n\r\n".encode())
191
212
  with open(filepath, "rb") as f:
192
213
  body.write(f.read())
@@ -197,7 +218,7 @@ def _openai_edit(
197
218
  add_field("model", model)
198
219
  add_field("n", str(n))
199
220
  add_field("size", size)
200
- if model == "gpt-image-1":
221
+ if model.startswith("gpt-image-"):
201
222
  add_field("quality", quality)
202
223
  body.write(f"--{boundary}--\r\n".encode())
203
224
 
@@ -220,9 +241,7 @@ def _openai_request(req: urllib.request.Request) -> tuple[bytes, str]:
220
241
  response = json.loads(resp.read().decode("utf-8"))
221
242
  except urllib.error.HTTPError as e:
222
243
  detail = e.read().decode("utf-8", errors="ignore")
223
- raise RuntimeError(
224
- f"OpenAI API error (HTTP {e.code}): {detail or e.reason}"
225
- ) from e
244
+ raise RuntimeError(f"OpenAI API error (HTTP {e.code}): {detail or e.reason}") from e
226
245
 
227
246
  data_list = response.get("data", [])
228
247
  if not data_list:
@@ -237,33 +256,40 @@ def _openai_request(req: urllib.request.Request) -> tuple[bytes, str]:
237
256
 
238
257
  # --- Gemini Generation ---
239
258
 
259
+
240
260
  def generate_gemini(
241
261
  api_key: str,
242
262
  prompt: str,
243
263
  model: str,
244
264
  aspect_ratio: str,
265
+ resolution: str = "1K",
245
266
  reference_path: str | None = None,
246
267
  ) -> tuple[bytes, str]:
247
268
  """Generate image via Gemini API."""
248
- url = (
249
- "https://generativelanguage.googleapis.com/"
250
- f"v1beta/models/{model}:generateContent?key={api_key}"
251
- )
269
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
252
270
 
253
271
  parts: list[dict] = []
254
272
  if reference_path and Path(reference_path).is_file():
255
- parts.append({
256
- "inlineData": {
257
- "mimeType": safe_mime(reference_path),
258
- "data": encode_file(reference_path),
273
+ parts.append(
274
+ {
275
+ "inlineData": {
276
+ "mimeType": safe_mime(reference_path),
277
+ "data": encode_file(reference_path),
278
+ }
259
279
  }
260
- })
280
+ )
261
281
  parts.append({"text": prompt})
262
282
 
283
+ image_config: dict = {"aspectRatio": aspect_ratio}
284
+ if resolution and resolution != "1K":
285
+ # gemini-3.1-flash-image-preview currently ignores this and returns ~1K (Google bug);
286
+ # gemini-3-pro-image-preview honors it. Send anyway so it works once Flash is fixed.
287
+ image_config["imageSize"] = resolution
288
+
263
289
  payload = {
264
290
  "contents": [{"parts": parts}],
265
291
  "generationConfig": {
266
- "imageConfig": {"aspectRatio": aspect_ratio},
292
+ "imageConfig": image_config,
267
293
  "responseModalities": ["TEXT", "IMAGE"],
268
294
  },
269
295
  }
@@ -280,9 +306,7 @@ def generate_gemini(
280
306
  response = json.loads(resp.read().decode("utf-8"))
281
307
  except urllib.error.HTTPError as e:
282
308
  detail = e.read().decode("utf-8", errors="ignore")
283
- raise RuntimeError(
284
- f"Gemini API error (HTTP {e.code}): {detail or e.reason}"
285
- ) from e
309
+ raise RuntimeError(f"Gemini API error (HTTP {e.code}): {detail or e.reason}") from e
286
310
 
287
311
  candidates = response.get("candidates", [])
288
312
  if not candidates:
@@ -298,13 +322,12 @@ def generate_gemini(
298
322
  if mime and img_data:
299
323
  return base64.b64decode(img_data), mime
300
324
 
301
- raise RuntimeError(
302
- f"No image in Gemini response: {json.dumps(response, indent=2)}"
303
- )
325
+ raise RuntimeError(f"No image in Gemini response: {json.dumps(response, indent=2)}")
304
326
 
305
327
 
306
328
  # --- CLI ---
307
329
 
330
+
308
331
  def main() -> None:
309
332
  parser = argparse.ArgumentParser(
310
333
  description="Generate images using OpenAI (default) or Gemini.",
@@ -319,39 +342,58 @@ Examples:
319
342
  """,
320
343
  )
321
344
  parser.add_argument(
322
- "--provider", choices=["openai", "gemini"], default="openai",
345
+ "--provider",
346
+ choices=["openai", "gemini"],
347
+ default="openai",
323
348
  help="Image generation provider. Default: openai.",
324
349
  )
325
350
  parser.add_argument(
326
- "--prompt", required=True,
351
+ "--prompt",
352
+ required=True,
327
353
  help="Text prompt describing the image to generate.",
328
354
  )
329
355
  parser.add_argument(
330
- "--reference", default=None,
356
+ "--reference",
357
+ default=None,
331
358
  help="Path to a reference image. OpenAI uses edit mode; Gemini includes it as context.",
332
359
  )
333
360
  parser.add_argument(
334
- "--model", default=None,
361
+ "--model",
362
+ default=None,
335
363
  help=f"Model override. Defaults: OpenAI={OPENAI_DEFAULT_MODEL}, Gemini={GEMINI_DEFAULT_MODEL}.",
336
364
  )
337
365
  parser.add_argument(
338
- "--aspect-ratio", default=DEFAULT_ASPECT_RATIO, choices=ALLOWED_ASPECT_RATIOS,
366
+ "--aspect-ratio",
367
+ default=DEFAULT_ASPECT_RATIO,
368
+ choices=ALLOWED_ASPECT_RATIOS,
339
369
  help=f"Aspect ratio. Default: {DEFAULT_ASPECT_RATIO}.",
340
370
  )
341
371
  parser.add_argument(
342
- "--quality", default="auto", choices=OPENAI_QUALITIES,
343
- help="OpenAI quality (gpt-image-1 only). Default: auto.",
372
+ "--quality",
373
+ default="auto",
374
+ choices=OPENAI_QUALITIES,
375
+ help="OpenAI quality (all gpt-image-* models). Default: auto.",
376
+ )
377
+ parser.add_argument(
378
+ "--resolution",
379
+ default="1K",
380
+ choices=ALLOWED_RESOLUTIONS,
381
+ help="Output resolution. OpenAI: 1K|2K (2K needs gpt-image-2). Gemini: 1K|2K|4K (2K/4K reliable on Pro only). Default: 1K.",
344
382
  )
345
383
  parser.add_argument(
346
- "--n", type=int, default=1,
384
+ "--n",
385
+ type=int,
386
+ default=1,
347
387
  help="Number of images (OpenAI only). Default: 1.",
348
388
  )
349
389
  parser.add_argument(
350
- "--output", default=None,
390
+ "--output",
391
+ default=None,
351
392
  help="Output path. Directory = timestamped file. Default: /tmp/.",
352
393
  )
353
394
  parser.add_argument(
354
- "--api-key", default=None,
395
+ "--api-key",
396
+ default=None,
355
397
  help="API key override. Otherwise reads from env var or ~/.niahere/config.yaml.",
356
398
  )
357
399
  args = parser.parse_args()
@@ -364,8 +406,7 @@ Examples:
364
406
  config_key = "openai_api_key" if provider == "openai" else "gemini_api_key"
365
407
  env_var = "OPENAI_API_KEY" if provider == "openai" else "GEMINI_API_KEY"
366
408
  raise SystemExit(
367
- f"Missing API key. Provide --api-key, set {env_var} in environment, "
368
- f"or add {config_key} to {NIA_CONFIG}."
409
+ f"Missing API key. Provide --api-key, set {env_var} in environment, or add {config_key} to {NIA_CONFIG}."
369
410
  )
370
411
 
371
412
  if args.reference and not Path(args.reference).expanduser().is_file():
@@ -375,15 +416,29 @@ Examples:
375
416
 
376
417
  try:
377
418
  if provider == "openai":
378
- size = OPENAI_SIZE_MAP.get(args.aspect_ratio, "1024x1024")
419
+ if args.resolution == "4K":
420
+ raise SystemExit("OpenAI does not support 4K via this script — use --provider gemini --resolution 4K.")
421
+ if args.resolution == "2K" and model != "gpt-image-2":
422
+ raise SystemExit(f"2K is only supported on gpt-image-2 (got --model {model}).")
423
+ size_map = OPENAI_SIZE_MAP_2K if args.resolution == "2K" else OPENAI_SIZE_MAP_1K
424
+ size = size_map.get(args.aspect_ratio, size_map["1:1"])
379
425
  image_data, mime = generate_openai(
380
- api_key=api_key, prompt=args.prompt, model=model,
381
- size=size, quality=args.quality, reference_path=ref, n=args.n,
426
+ api_key=api_key,
427
+ prompt=args.prompt,
428
+ model=model,
429
+ size=size,
430
+ quality=args.quality,
431
+ reference_path=ref,
432
+ n=args.n,
382
433
  )
383
434
  else:
384
435
  image_data, mime = generate_gemini(
385
- api_key=api_key, prompt=args.prompt, model=model,
386
- aspect_ratio=args.aspect_ratio, reference_path=ref,
436
+ api_key=api_key,
437
+ prompt=args.prompt,
438
+ model=model,
439
+ aspect_ratio=args.aspect_ratio,
440
+ resolution=args.resolution,
441
+ reference_path=ref,
387
442
  )
388
443
 
389
444
  ext = ".png" if "png" in mime else ".jpg"
@@ -391,7 +446,7 @@ Examples:
391
446
  out.parent.mkdir(parents=True, exist_ok=True)
392
447
  out.write_bytes(image_data)
393
448
  print(f"Saved: {out}")
394
- print(f"Provider: {provider} | Model: {model} | Size/Ratio: {args.aspect_ratio}")
449
+ print(f"Provider: {provider} | Model: {model} | Ratio: {args.aspect_ratio} | Resolution: {args.resolution}")
395
450
  except Exception as exc:
396
451
  print(f"Error: {exc}", file=sys.stderr)
397
452
  raise SystemExit(1) from exc