ima2-gen 2.0.2 → 2.0.4-preview-260626

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/bin/commands/gen.js +7 -5
  3. package/bin/commands/multimode.js +7 -5
  4. package/bin/commands/node.js +2 -1
  5. package/config.js +8 -1
  6. package/docs/API.md +5 -4
  7. package/docs/CLI.md +4 -4
  8. package/docs/FAQ.ko.md +1 -1
  9. package/docs/FAQ.md +1 -1
  10. package/docs/IMAGE_RESOLUTION.md +29 -0
  11. package/docs/migration/runtime-test-inventory.md +11 -1
  12. package/lib/agentCommandParser.js +10 -6
  13. package/lib/agentGenerationPlanner.js +96 -19
  14. package/lib/agentImageVideoGen.js +26 -6
  15. package/lib/agentPlannerModel.js +172 -0
  16. package/lib/agentQuestionResponder.js +9 -3
  17. package/lib/agentQueueStore.js +42 -0
  18. package/lib/agentQueueWorker.js +57 -7
  19. package/lib/agentRuntime.js +96 -12
  20. package/lib/agentSettings.js +9 -6
  21. package/lib/agentToolManifest.js +90 -0
  22. package/lib/agentTypes.js +1 -0
  23. package/lib/agyCli.js +31 -0
  24. package/lib/agyImageAdapter.js +73 -8
  25. package/lib/capabilities.js +7 -5
  26. package/lib/configKeys.js +6 -0
  27. package/lib/geminiApiImageAdapter.js +11 -4
  28. package/lib/generationRequestLog.js +25 -0
  29. package/lib/grokImageAdapter.js +27 -34
  30. package/lib/grokMultimodeAdapter.js +2 -1
  31. package/lib/grokVideoAdapter.js +3 -2
  32. package/lib/grokVideoPlannerPrompt.js +18 -25
  33. package/lib/inflight.js +1 -1
  34. package/lib/multimodeHelpers.js +3 -2
  35. package/lib/oauthProxy/generators.js +6 -5
  36. package/lib/oauthProxy/prompts.js +55 -16
  37. package/lib/oauthProxy/streams.js +1 -1
  38. package/lib/promptSafetyPolicy.js +1 -1
  39. package/lib/responsesDoctor.js +3 -3
  40. package/lib/responsesFallback.js +27 -14
  41. package/lib/responsesImageAdapter.js +11 -7
  42. package/node_modules/qs/CHANGELOG.md +10 -0
  43. package/node_modules/qs/README.md +1 -1
  44. package/node_modules/qs/dist/qs.js +15 -15
  45. package/node_modules/qs/eslint.config.mjs +1 -0
  46. package/node_modules/qs/lib/parse.js +52 -22
  47. package/node_modules/qs/lib/stringify.js +11 -4
  48. package/node_modules/qs/package.json +2 -2
  49. package/node_modules/qs/test/parse.js +49 -0
  50. package/node_modules/qs/test/stringify.js +129 -0
  51. package/package.json +1 -1
  52. package/routes/agent.js +19 -2
  53. package/routes/agy.js +5 -1
  54. package/routes/generate.js +12 -1
  55. package/routes/generationRequestLog.js +16 -0
  56. package/routes/index.js +2 -0
  57. package/routes/keys.js +22 -2
  58. package/routes/multimode.js +1 -1
  59. package/routes/nodes.js +5 -5
  60. package/ui/dist/.vite/manifest.json +22 -12
  61. package/ui/dist/assets/AgentWorkspace-CMP9XZrL.js +3 -0
  62. package/ui/dist/assets/{CardNewsWorkspace-Dav3K5CT.js → CardNewsWorkspace-DtOwgYzf.js} +1 -1
  63. package/ui/dist/assets/GenerationRequestLogPanel-CB1i5dWd.js +1 -0
  64. package/ui/dist/assets/{NodeCanvas-C4ifFzB1.js → NodeCanvas-DwX2jRHf.js} +3 -3
  65. package/ui/dist/assets/{PromptBuilderPanel-CEcyU9PL.js → PromptBuilderPanel-B3VhUc5u.js} +2 -2
  66. package/ui/dist/assets/{PromptImportDialog-CgQ94Gth.js → PromptImportDialog-4FQd2YVS.js} +2 -2
  67. package/ui/dist/assets/{PromptImportDiscoverySection-CuzyzbNI.js → PromptImportDiscoverySection-CTNr6ft3.js} +1 -1
  68. package/ui/dist/assets/PromptImportFolderSection-zCJ1lBH8.js +1 -0
  69. package/ui/dist/assets/{PromptLibraryPanel-BOe18we8.js → PromptLibraryPanel-B784T1KR.js} +2 -2
  70. package/ui/dist/assets/SettingsWorkspace-BpM6lWH-.js +1 -0
  71. package/ui/dist/assets/index-2oG6in1i.css +1 -0
  72. package/ui/dist/assets/index-BzjrmmIG.js +23 -0
  73. package/ui/dist/assets/index-CwAzer33.js +4 -0
  74. package/ui/dist/index.html +2 -2
  75. package/ui/dist/assets/AgentWorkspace-Dth6YijN.js +0 -3
  76. package/ui/dist/assets/PromptImportFolderSection-DHLGlO6l.js +0 -1
  77. package/ui/dist/assets/SettingsWorkspace-Cdgnm4Wa.js +0 -1
  78. package/ui/dist/assets/index-C5PSahkr.js +0 -1
  79. package/ui/dist/assets/index-Dn2AhL6d.css +0 -1
  80. package/ui/dist/assets/index-Tjqx6wUV.js +0 -23
package/CHANGELOG.md CHANGED
@@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
30
30
  - Node route validation order — `startJob`/202 response moved after request validation.
31
31
  - CI typecheck — unused imports in card-news tests and store split type mismatches.
32
32
  - Thumbnail backfill failure reporting (#94).
33
+ - AGY provider detection now finds user-local `agy` installs such as `~/.local/bin/agy` and supports `IMA2_AGY_BIN` for explicit binary paths.
33
34
  - AGY Windows pipe handling, Gemini API aspect ratio string values, multimode same-prompt batching.
34
35
  - Moderation over-filtering — removed safety tags and added error enrichment.
35
36
 
@@ -9,6 +9,8 @@ const VALID_MODES = new Set(["auto", "direct"]);
9
9
  const VALID_MODERATION = new Set(["auto", "low"]);
10
10
  const VALID_PROVIDERS = new Set(["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api"]);
11
11
  const KNOWN_IMAGE_MODELS = new Set(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", "grok-imagine-image", "grok-imagine-image-quality", "nano-banana-2", "nano-banana-pro"]);
12
+ const MAX_GENERATION_COUNT = Math.max(1, Math.trunc(Number(config.limits.maxGeneratedImages) || 24));
13
+ const MAX_REFERENCE_COUNT = Math.max(1, Math.trunc(Number(config.limits.maxRefCount) || 5));
12
14
  const SPEC = {
13
15
  flags: {
14
16
  quality: { short: "q", type: "string", default: "low" },
@@ -47,8 +49,8 @@ const HELP = `
47
49
  Options:
48
50
  -q, --quality <low|medium|high> Default: low
49
51
  -s, --size <WxH | auto> Default: 1024x1024
50
- -n, --count <1..8> Default: 1
51
- --ref <file> Attach reference image (repeatable, max 5)
52
+ -n, --count <1..${MAX_GENERATION_COUNT}> Default: 1
53
+ --ref <file> Attach reference image (repeatable, max ${MAX_REFERENCE_COUNT})
52
54
  -o, --out <file> Single-image output path (implies -n 1)
53
55
  -d, --out-dir <dir> Output dir for multiple images
54
56
  --json Print JSON result to stdout
@@ -88,8 +90,8 @@ export default async function genCmd(argv) {
88
90
  if (!prompt)
89
91
  die(2, "prompt is required (positional or via --stdin)");
90
92
  const refs = (Array.isArray(args.ref) ? args.ref : []);
91
- if (refs.length > 5)
92
- die(2, "max 5 --ref attachments");
93
+ if (refs.length > MAX_REFERENCE_COUNT)
94
+ die(2, `max ${MAX_REFERENCE_COUNT} --ref attachments`);
93
95
  if (!VALID_MODES.has(String(args.mode)))
94
96
  die(2, "--mode must be one of: auto, direct");
95
97
  if (!VALID_MODERATION.has(String(args.moderation)))
@@ -107,7 +109,7 @@ export default async function genCmd(argv) {
107
109
  if (args["web-search"] && args["no-web-search"]) {
108
110
  die(2, "--web-search and --no-web-search are mutually exclusive");
109
111
  }
110
- const n = Math.max(1, Math.min(8, parseInt(String(args.count)) || 1));
112
+ const n = Math.max(1, Math.min(MAX_GENERATION_COUNT, parseInt(String(args.count)) || 1));
111
113
  const timeoutMs = (parseInt(String(args.timeout)) || 180) * 1000;
112
114
  let server;
113
115
  try {
@@ -5,6 +5,8 @@ import { dataUriToFile, defaultOutName, fileToDataUri } from "../lib/files.js";
5
5
  import { out, die, color, json, exitCodeForError } from "../lib/output.js";
6
6
  import { config } from "../../config.js";
7
7
  import { createCliRequestId, recoverGeneratedOutputs, formatRecoveryHint } from "../lib/recover-output.js";
8
+ const MAX_GENERATION_COUNT = Math.max(1, Math.trunc(Number(config.limits.maxGeneratedImages) || 24));
9
+ const MAX_REFERENCE_COUNT = Math.max(1, Math.trunc(Number(config.limits.maxRefCount) || 5));
8
10
  const SPEC = {
9
11
  flags: {
10
12
  quality: { short: "q", type: "string", default: "low" },
@@ -36,7 +38,7 @@ const HELP = `
36
38
  Options:
37
39
  -q, --quality <low|medium|high> Default: low
38
40
  -s, --size <WxH> Default: 1024x1024
39
- --max-images <1..8> Default: 4
41
+ --max-images <1..${MAX_GENERATION_COUNT}> Default: 4
40
42
  -o, --out <file> First image (implies --max-images 1)
41
43
  -d, --out-dir <dir> Output dir for multiple images
42
44
  --json
@@ -44,7 +46,7 @@ const HELP = `
44
46
  --provider <auto|oauth|api|grok|grok-api|agy|gemini-api>
45
47
  Provider (oauth = GPT OAuth; grok = xAI Grok; agy/gemini-api = Gemini)
46
48
  --mode <auto|direct> Prompt handling mode. Default: auto
47
- --ref <file> Attach reference image (repeatable, max 5)
49
+ --ref <file> Attach reference image (repeatable, max ${MAX_REFERENCE_COUNT})
48
50
  --reasoning-effort <none|low|medium|high|xhigh>
49
51
  --web-search / --no-web-search
50
52
  --moderation <auto|low>
@@ -83,10 +85,10 @@ export default async function multimodeCmd(argv) {
83
85
  die(exitCodeForError(e), e.message);
84
86
  throw e;
85
87
  }
86
- const maxImages = Math.max(1, Math.min(8, parseInt(String(args["max-images"])) || 4));
88
+ const maxImages = Math.max(1, Math.min(MAX_GENERATION_COUNT, parseInt(String(args["max-images"])) || 4));
87
89
  const refs = (Array.isArray(args.ref) ? args.ref : []);
88
- if (refs.length > 5)
89
- die(2, "max 5 --ref attachments");
90
+ if (refs.length > MAX_REFERENCE_COUNT)
91
+ die(2, `max ${MAX_REFERENCE_COUNT} --ref attachments`);
90
92
  const references = await Promise.all(refs.map((p) => fileToDataUri(p)));
91
93
  const outDir = args["out-dir"] ? String(args["out-dir"]) : null;
92
94
  const explicitOut = args.out ? String(args.out) : null;
@@ -4,6 +4,7 @@ import { streamSse } from "../lib/sse.js";
4
4
  import { fileToDataUri, dataUriToFile, defaultOutName } from "../lib/files.js";
5
5
  import { out, die, color, json, exitCodeForError } from "../lib/output.js";
6
6
  import { config } from "../../config.js";
7
+ const MAX_GENERATION_COUNT = Math.max(1, Math.trunc(Number(config.limits.maxGeneratedImages) || 24));
7
8
  const HELP = `
8
9
  ima2 node <subcommand> [options]
9
10
 
@@ -75,7 +76,7 @@ async function generateSub(argv) {
75
76
  prompt,
76
77
  quality: args.quality,
77
78
  size: args.size,
78
- n: Math.max(1, Math.min(8, parseInt(String(args.count)) || 1)),
79
+ n: Math.max(1, Math.min(MAX_GENERATION_COUNT, parseInt(String(args.count)) || 1)),
79
80
  references,
80
81
  moderation: args.moderation,
81
82
  sessionId: args.session,
package/config.js CHANGED
@@ -79,7 +79,8 @@ export const config = {
79
79
  maxRefB64Bytes: pickInt(env.IMA2_MAX_REF_B64_BYTES, fileCfg.limits?.maxRefB64Bytes, 7 * 1024 * 1024),
80
80
  maxMetadataReadB64Bytes: pickInt(env.IMA2_MAX_METADATA_READ_B64_BYTES, fileCfg.limits?.maxMetadataReadB64Bytes, 12 * 1024 * 1024),
81
81
  maxRefCount: pickInt(env.IMA2_MAX_REF_COUNT, fileCfg.limits?.maxRefCount, 5),
82
- maxParallel: pickInt(env.IMA2_MAX_PARALLEL, fileCfg.limits?.maxParallel, 8),
82
+ maxGeneratedImages: pickInt(env.IMA2_MAX_GENERATED_IMAGES, fileCfg.limits?.maxGeneratedImages, 24),
83
+ maxParallel: pickInt(env.IMA2_MAX_PARALLEL, fileCfg.limits?.maxParallel, 24),
83
84
  graphMaxNodes: pickInt(env.IMA2_GRAPH_MAX_NODES, fileCfg.limits?.graphMaxNodes, 500),
84
85
  graphMaxEdges: pickInt(env.IMA2_GRAPH_MAX_EDGES, fileCfg.limits?.graphMaxEdges, 1000),
85
86
  promptImportMaxFileBytes: pickInt(env.IMA2_PROMPT_IMPORT_MAX_FILE_BYTES, fileCfg.limits?.promptImportMaxFileBytes, 512 * 1024),
@@ -133,6 +134,7 @@ export const config = {
133
134
  promptImportDiscoveryRegistryFile: pickStr(env.IMA2_PROMPT_IMPORT_DISCOVERY_REGISTRY_FILE, fileCfg.storage?.promptImportDiscoveryRegistryFile, join(configDir, "prompt-import-discovery.json")),
134
135
  configFile: join(configDir, "config.json"),
135
136
  advertiseFile: pickStr(env.IMA2_ADVERTISE_FILE, fileCfg.storage?.advertiseFile, join(configDir, "server.json")),
137
+ generationRequestLogFile: pickStr(env.IMA2_GENERATION_REQUEST_LOG_FILE, fileCfg.storage?.generationRequestLogFile, join(configDir, "generation-request-log.json")),
136
138
  staticMaxAge: pickStr(env.IMA2_STATIC_MAX_AGE, fileCfg.storage?.staticMaxAge, "1y"),
137
139
  },
138
140
  ids: {
@@ -193,6 +195,10 @@ export const config = {
193
195
  timeoutMs: pickInt(env.IMA2_CARD_NEWS_PLANNER_TIMEOUT_MS, fileCfg.cardNewsPlanner?.timeoutMs, 60_000),
194
196
  deterministicFallback: pickBool(env.IMA2_CARD_NEWS_PLANNER_FALLBACK, fileCfg.cardNewsPlanner?.deterministicFallback, false),
195
197
  },
198
+ agentPlanner: {
199
+ enabled: pickBool(env.IMA2_AGENT_PLANNER_ENABLED, fileCfg.agentPlanner?.enabled, true),
200
+ timeoutMs: pickInt(env.IMA2_AGENT_PLANNER_TIMEOUT_MS, fileCfg.agentPlanner?.timeoutMs, 30_000),
201
+ },
196
202
  comfy: {
197
203
  defaultUrl: pickStr(env.IMA2_COMFY_URL, fileCfg.comfy?.defaultUrl, "http://127.0.0.1:8188"),
198
204
  uploadTimeoutMs: pickPositiveInt(env.IMA2_COMFY_UPLOAD_TIMEOUT_MS, fileCfg.comfy?.uploadTimeoutMs, 30_000),
@@ -216,6 +222,7 @@ export const GENERATED_DIR = config.storage.generatedDir;
216
222
  export const BODY_LIMIT = config.server.bodyLimit;
217
223
  export const MAX_REF_B64_BYTES = config.limits.maxRefB64Bytes;
218
224
  export const MAX_REFS = config.limits.maxRefCount;
225
+ export const MAX_GENERATED_IMAGES = config.limits.maxGeneratedImages;
219
226
  export const MAX_N = config.limits.maxParallel;
220
227
  export const INFLIGHT_TTL_MS = config.inflight.ttlMs;
221
228
  export const INFLIGHT_REAP_MS = config.inflight.reapMs;
package/docs/API.md CHANGED
@@ -17,7 +17,7 @@ Image generation supports OAuth, API-key, Grok, and Gemini (`agy` and `gemini-ap
17
17
  - `provider: "grok"` uses the bundled progrok xAI proxy. Classic, Node, and Agent generation run mandatory xAI Web Search through `/v1/responses`, then run a `grok-4.3` planner call with a forced local `generate_image` function, then ima2 executes xAI `/v1/images/generations`. If reference images, a Node parent image, or an Agent current image are attached, the final step switches to xAI `/v1/images/edits` so image-to-image context is preserved.
18
18
  - `provider: "agy"` spawns the Antigravity CLI (`agy -p`) to generate images via Google Gemini's `default_api:generate_image` tool. Model is `nano-banana-2`. Output is fixed at 1024×1024 JPEG. Max 3 reference images (i2i). No web search, quality, size, or mask controls. Multimode returns a single image. Video is unsupported (`AGY_VIDEO_UNSUPPORTED`).
19
19
  - `provider: "grok-api"` uses a direct xAI API key instead of the bundled progrok OAuth proxy. Same pipeline as `grok` (Web Search → planner → `/v1/images/generations`), same aspect ratio and resolution options. Requires an xAI API key configured via the web UI key management or `XAI_API_KEY` env var. Also supports video generation.
20
- - `provider: "gemini-api"` calls the Google Generative Language API directly (or Vertex AI with a service account JSON). Supports models `nano-banana-2` (Gemini 3.1 Flash Image) and `nano-banana-pro` (Gemini 3 Pro Image). Supports variable aspect ratios (1:1 through 21:9) and four resolution tiers (512px, 1K, 2K, 4K); these are honored only on the direct API path the Vertex AI endpoint (`aiplatform.googleapis.com`) rejects the `response_format` field and always returns a default 1K/1:1 image regardless of requested size. Auth: `GEMINI_API_KEY` env var, web UI key management (`/api/keys/gemini`), or a Vertex AI service account JSON (`VERTEX_SERVICE_ACCOUNT_JSON` or `/api/keys/vertex`). When both Vertex credentials and an API key are configured, Vertex takes priority. The chosen auth mode (`apikey` or `vertex`) persists to `~/.ima2/config.json` as `geminiAuthMode` and is restored on server startup. Per-model cost: `nano-banana-2` (Flash): 512=$0.001, 1K=$0.003, 2K=$0.004, 4K=$0.006; `nano-banana-pro`: 1K=$0.007, 2K=$0.007, 4K=$0.013. No web search or mask controls.
20
+ - `provider: "gemini-api"` calls the Google Generative Language API directly (or Vertex AI with a service account JSON). Supports models `nano-banana-2` (Gemini 3.1 Flash Image) and `nano-banana-pro` (Gemini 3 Pro Image). Supports variable aspect ratios (1:1 through 21:9) and four resolution tiers (512px, 1K, 2K, 4K) on both auth paths the direct API path sends `generation_config.response_format.image` (snake_case) while the Vertex AI endpoint (`aiplatform.googleapis.com`) sends `generationConfig.imageConfig` (camelCase). With `size: "auto"` the image config is omitted entirely and the model decides ratio/size. Auth: `GEMINI_API_KEY` env var, web UI key management (`/api/keys/gemini`), or a Vertex AI service account JSON (`VERTEX_SERVICE_ACCOUNT_JSON` or `/api/keys/vertex`). When both Vertex credentials and an API key are configured, Vertex takes priority. The chosen auth mode (`apikey` or `vertex`) persists to `~/.ima2/config.json` as `geminiAuthMode` and is restored on server startup. Per-model cost: `nano-banana-2` (Flash): 512=$0.001, 1K=$0.003, 2K=$0.004, 4K=$0.006; `nano-banana-pro`: 1K=$0.007, 2K=$0.007, 4K=$0.013. No web search or mask controls.
21
21
  - API-key generation covers classic generate, edit, mask-guided edit, multimode, and node generation.
22
22
  - If `provider: "api"` is requested without an API key, routes fail before upstream with `401` and `API_KEY_REQUIRED`.
23
23
  - Grok generation maps `size` to xAI `aspect_ratio` and `resolution`; it does not send an OpenAI-style `size` field upstream. Grok edit uses xAI `/v1/images/edits`; Grok mask edit remains unsupported and returns `GROK_MASK_UNSUPPORTED`.
@@ -163,7 +163,7 @@ data: {"requestId":"req_abc","jobId":"req_abc","phase":"streaming"}
163
163
  |---|---|---|
164
164
  | Accepted | `202` | `{ "requestId": "req_xxx" }` |
165
165
  | Duplicate active `requestId` | `409` | `REQUEST_ID_IN_USE` |
166
- | More than 12 concurrent active jobs | `429` | `TOO_MANY_JOBS` with `Retry-After: 5` |
166
+ | More than the configured concurrent active job limit | `429` | `TOO_MANY_JOBS` with `Retry-After: 5`; default limit is `24` via `IMA2_MAX_PARALLEL` |
167
167
 
168
168
  Progress events are published on `GET /api/events`. The POST response returns immediately; clients must not expect SSE on the POST connection when `async: true`.
169
169
 
@@ -604,7 +604,7 @@ Style-sheet extraction can require an API key/openai client. Image generation al
604
604
  | `VIDEO_PROVIDER_UNSUPPORTED` | Video generation requires provider `"grok"` or `"grok-api"` |
605
605
  | `SSE_CAPACITY` | More than 512 concurrent `GET /api/events` listeners |
606
606
  | `REQUEST_ID_IN_USE` | Async POST used a `requestId` that already has an active job |
607
- | `TOO_MANY_JOBS` | More than 12 concurrent active generation jobs (`Retry-After: 5`) |
607
+ | `TOO_MANY_JOBS` | More than the configured concurrent active generation job limit (`Retry-After: 5`; default `24`) |
608
608
 
609
609
  ## Key Management
610
610
 
@@ -612,11 +612,12 @@ API key management endpoints for configuring provider credentials at runtime thr
612
612
 
613
613
  | Endpoint | Method | Description |
614
614
  |---|---|---|
615
- | `/api/keys/status` | GET | Returns configured/valid/maskedKey status for all providers (openai, xai, gemini, vertex) |
615
+ | `/api/keys/status` | GET | Returns configured/valid/maskedKey status for all providers (openai, xai, gemini, vertex) plus `geminiAuthMode` (`"apikey"` or `"vertex"`) |
616
616
  | `/api/keys/:provider` | PUT | Save an API key. Body: `{ "apiKey": "..." }`. Validates key format and upstream before saving to config.json. Provider: `openai`, `xai`, or `gemini`. |
617
617
  | `/api/keys/:provider` | DELETE | Remove a config-sourced API key. Env-sourced keys cannot be removed (`ENV_KEY_IMMUTABLE`). |
618
618
  | `/api/keys/vertex` | PUT | Save a Vertex AI service account JSON. Body: `{ "serviceAccountJson": "..." }`. Validates JSON structure (`type: "service_account"`, `project_id` required). |
619
619
  | `/api/keys/vertex` | DELETE | Remove a config-sourced Vertex AI service account. |
620
+ | `/api/keys/gemini-auth-mode` | PUT | Persist the Gemini auth mode chosen in the settings dropdown. Body: `{ "mode": "apikey" \| "vertex" }`. Saved to `config.json` and hot-updated. |
620
621
 
621
622
  Keys saved via PUT are stored in `config.json` and hot-updated in the runtime context (no server restart required). Keys loaded from environment variables (`OPENAI_API_KEY`, `XAI_API_KEY`, `GEMINI_API_KEY`, `VERTEX_SERVICE_ACCOUNT_JSON`) take precedence and are immutable through the API.
622
623
 
package/docs/CLI.md CHANGED
@@ -41,7 +41,7 @@ Agents should start from the packaged skill and capability commands instead of g
41
41
  | `ima2 defaults --json` | Print the running server's effective model/reasoning defaults, falling back to local config when no server is reachable |
42
42
  | `ima2 defaults --local --json` | Print local effective defaults without contacting the server |
43
43
 
44
- `ima2 capabilities --json` separates supported and unsupported model ids. Agents should use only `valid.imageModels.supported` for generation/default choices. `limits.maxParallel` is advisory queue guidance; it is not a server-side concurrency semaphore.
44
+ `ima2 capabilities --json` separates supported and unsupported model ids. Agents should use only `valid.imageModels.supported` for generation/default choices. `limits.maxGeneratedImages` reports the configured per-request image count limit, and `limits.maxParallel` reports the enforced server-side inflight capacity guard.
45
45
 
46
46
  ## Generation
47
47
 
@@ -61,7 +61,7 @@ Provider override semantics:
61
61
  - `api` forces the API-key Responses path and requires a configured API key.
62
62
  - `oauth` forces the local OAuth proxy path.
63
63
  - `grok` uses the bundled progrok xAI proxy (`127.0.0.1:18645`). Classic generation first runs mandatory xAI Web Search through Responses API, then asks `grok-4.3` to call ima2's local `generate_image` tool, then ima2 executes xAI `/v1/images/generations`. If `--ref` images are attached, the final step uses xAI `/v1/images/edits` instead so image-to-image/reference context is preserved. Models: `grok-imagine-image`, `grok-imagine-image-quality`. Size is mapped to xAI `aspect_ratio` and `resolution`; the UI web-search toggle is OpenAI-provider-only because Grok search is always on in this path.
64
- - `agy` spawns the Antigravity CLI to generate via Google Gemini (`nano-banana-2`). Fixed 1024×1024 JPEG output, max 3 refs. No web search, quality, size, or mask controls.
64
+ - `agy` spawns the Antigravity CLI to generate via Google Gemini (`nano-banana-2`). Fixed 1024×1024 JPEG output, max 3 refs. No web search, quality, size, or mask controls. If `agy` is not on the server process PATH, ima2 also checks common user-local installs such as `~/.local/bin/agy`; set `IMA2_AGY_BIN=/absolute/path/to/agy` to force a specific binary.
65
65
  - `gemini-api` calls the Google Generative Language API directly. Models: `nano-banana-2` (Gemini 3.1 Flash Image) and `nano-banana-pro` (Gemini 3 Pro Image). Use `--model nano-banana-2` or `--model nano-banana-pro` to select. Supports `--size` for aspect ratio and resolution (512px–4K) on the direct API path; Vertex AI ignores aspect/size. Requires `GEMINI_API_KEY` or a Vertex AI service account (`VERTEX_SERVICE_ACCOUNT_JSON`). Switching from `agy` or `gemini-api` provider auto-selects the corresponding Gemini model; switching away resets to the GPT default.
66
66
  - `auto` preserves route default behavior and currently resolves to GPT OAuth unless server routing changes.
67
67
 
@@ -108,7 +108,7 @@ mockup`.
108
108
  For dense or critical text, keep the text large and explicit. Exact placement,
109
109
  small text, and pixel-perfect typography can still need iteration or post-editing.
110
110
 
111
- Multimode-specific flags include `--max-images <1..8>`, `--ref <file>` (repeatable, max 5), `--mode <auto|direct>`, `--provider <auto|oauth|api|grok|grok-api|agy|gemini-api>`, and `--show-partial`. `ima2 edit --mask` remains intentionally deferred to #31 because current mask plumbing is guided edit rather than guaranteed true masked/inpaint semantics.
111
+ Multimode-specific flags include `--max-images <1..24>` by default (configurable through `IMA2_MAX_GENERATED_IMAGES`), `--ref <file>` (repeatable, max 5), `--mode <auto|direct>`, `--provider <auto|oauth|api|grok|grok-api|agy|gemini-api>`, and `--show-partial`. `ima2 edit --mask` remains intentionally deferred to #31 because current mask plumbing is guided edit rather than guaranteed true masked/inpaint semantics.
112
112
 
113
113
  ## Video
114
114
 
@@ -362,7 +362,7 @@ comfy.{defaultUrl,uploadTimeoutMs,maxUploadBytes}
362
362
  storage.{generatedDir,generatedDirName}
363
363
  server.{port,host,bodyLimit}
364
364
  oauth.{proxyPort,statusTimeoutMs,restartDelayMs}
365
- limits.{maxRefCount,maxParallel}
365
+ limits.{maxRefCount,maxGeneratedImages,maxParallel}
366
366
  history.{defaultPageSize,maxPageCap}
367
367
  ```
368
368
 
package/docs/FAQ.ko.md CHANGED
@@ -337,7 +337,7 @@ GPT OAuth는 OpenAI와 ChatGPT/Codex 관련 호스트 접근이 필요할 수
337
337
 
338
338
  ### 동시 작업 상한은 얼마인가요?
339
339
 
340
- 서버는 동시 생성 작업을 12건(`MAX_CONCURRENT_JOBS`)으로 제한합니다. 초과 요청은 `429`와 `Retry-After: 5`를 받습니다. SSE 엔드포인트 자체는 512개 동시 연결까지 지원합니다.
340
+ 서버는 동시 생성 작업을 설정값 `limits.maxParallel` 기준으로 제한합니다(기본 `24`, `IMA2_MAX_PARALLEL`로 변경 가능). 초과 요청은 `429`와 `Retry-After: 5`를 받습니다. SSE 엔드포인트 자체는 512개 동시 연결까지 지원합니다.
341
341
 
342
342
  ## CLI 점검 순서
343
343
 
package/docs/FAQ.md CHANGED
@@ -362,7 +362,7 @@ The event channel client reconnects automatically with exponential backoff. On r
362
362
 
363
363
  ### What is the maximum number of concurrent jobs?
364
364
 
365
- The server caps concurrent generation jobs at 12 (`MAX_CONCURRENT_JOBS`). Additional requests receive `429` with `Retry-After: 5`. The SSE endpoint itself caps at 512 simultaneous connections.
365
+ The server caps concurrent generation jobs at the configured `limits.maxParallel` value (default `24`, overridable with `IMA2_MAX_PARALLEL`). Additional requests receive `429` with `Retry-After: 5`. The SSE endpoint itself caps at 512 simultaneous connections.
366
366
 
367
367
  ## CLI troubleshooting checklist
368
368
 
@@ -0,0 +1,29 @@
1
+ # Image Resolution (OAuth path) — Temporary Limitation
2
+
3
+ OAuth (ChatGPT subscription / Codex backend) image generation has a backend-imposed
4
+ resolution behavior. This is documented here because it is a **temporary limitation
5
+ and the future direction is undecided**.
6
+
7
+ ## Behavior
8
+
9
+ - **Aspect ratio is honored** exactly per the requested `size`. The prompt carries a
10
+ directive (`You MUST generate this image at exactly WxH resolution as a TALL vertical
11
+ PORTRAIT / WIDE horizontal LANDSCAPE / SQUARE canvas`), so portrait/landscape/square
12
+ come out with the correct orientation.
13
+ - **Total pixels are capped at ~1.57M** (≈ 1024×1536) by the OAuth backend. 1K presets
14
+ are exact; larger requests keep the aspect ratio but scale down.
15
+
16
+ | requested `size` | actual PNG (OAuth) | note |
17
+ |---|---|---|
18
+ | 1024×1536 (portrait) | 1024×1536 | exact |
19
+ | 1536×1024 (landscape) | 1536×1024 | exact |
20
+ | 2048×1152 (16:9) | 1672×941 | aspect kept, scaled to ~1.57M |
21
+ | 2048×2048 (square) | 1254×1254 | aspect kept, scaled |
22
+
23
+ ## If you need exact large pixels
24
+
25
+ Use the **API-key path** (`/images/generations`, `gpt-image` — arbitrary resolutions up
26
+ to 3840×2160). The OAuth path is free (ChatGPT subscription) but pixel-capped.
27
+
28
+ > Verified by server `/api/generate` E2E on 2026-06-27.
29
+ > **This is a temporary limitation; the future direction is undecided.**
@@ -4,16 +4,20 @@ Generated by `npm run test:inventory` (script: `scripts/classify-tests.mjs`).
4
4
 
5
5
  _Tests considered "runtime-importing" if they import from `../lib/`, `../routes/`, `../bin/`, `../server`, or `../config`._
6
6
 
7
- Total: 191 (runtime: 66, contract: 125)
7
+ Total: 201 (runtime: 71, contract: 130)
8
8
 
9
9
  ## Runtime-importing tests
10
10
  - `tests/agent-mode-auto-planner-contract.test.ts`
11
+ - `tests/agent-mode-llm-planner-contract.test.ts`
11
12
  - `tests/agent-mode-parallel-contract.test.ts`
12
13
  - `tests/agent-mode-queue-contract.test.ts`
13
14
  - `tests/agent-mode-queue-migration-contract.test.ts`
14
15
  - `tests/agent-mode-runtime-contract.test.ts`
15
16
  - `tests/agent-mode-slash-command-contract.test.ts`
17
+ - `tests/agent-mode-ux-feedback-contract.test.ts`
16
18
  - `tests/agent-video-intent.test.ts`
19
+ - `tests/agy-artifact-fallback.test.ts`
20
+ - `tests/agy-cli.test.ts`
17
21
  - `tests/api-cache-policy.test.ts`
18
22
  - `tests/api-provider-parity.test.ts`
19
23
  - `tests/billing-source.test.ts`
@@ -29,6 +33,7 @@ Total: 191 (runtime: 66, contract: 125)
29
33
  - `tests/generate-route-validation-error.test.ts`
30
34
  - `tests/generated-static-privacy.test.ts`
31
35
  - `tests/generation-errors.test.ts`
36
+ - `tests/generation-request-log.test.ts`
32
37
  - `tests/grok-command-login-contract.test.ts`
33
38
  - `tests/grok-planner-adapter.test.ts`
34
39
  - `tests/grok-proxy-launcher.test.ts`
@@ -78,6 +83,7 @@ Total: 191 (runtime: 66, contract: 125)
78
83
  - `tests/agent-mode-frontend-contract.test.js`
79
84
  - `tests/agent-mode-layout-contract.test.js`
80
85
  - `tests/agent-mode-right-sidebar-contract.test.js`
86
+ - `tests/agent-mode-run-progress-contract.test.ts`
81
87
  - `tests/agent-mode-tool-folding-contract.test.js`
82
88
  - `tests/app-weight-splitting-contract.test.js`
83
89
  - `tests/async-capacity-retry-behavior.test.ts`
@@ -100,6 +106,7 @@ Total: 191 (runtime: 66, contract: 125)
100
106
  - `tests/canvas-hit-test-contract.test.js`
101
107
  - `tests/canvas-mask-contract.test.js`
102
108
  - `tests/canvas-memo-contract.test.js`
109
+ - `tests/canvas-memo-prompt-contract.test.js`
103
110
  - `tests/canvas-mode-contract.test.js`
104
111
  - `tests/canvas-object-key-contract.test.js`
105
112
  - `tests/canvas-persistence-contract.test.js`
@@ -142,6 +149,7 @@ Total: 191 (runtime: 66, contract: 125)
142
149
  - `tests/gallery-shortcuts-visible-domain-contract.test.js`
143
150
  - `tests/gallery-viewer-navigation-contract.test.js`
144
151
  - `tests/generation-controls-ux-contract.test.js`
152
+ - `tests/generation-limit-unlock-contract.test.js`
145
153
  - `tests/health.test.js`
146
154
  - `tests/history-index-contract.test.js`
147
155
  - `tests/history-permanent-delete.test.js`
@@ -165,6 +173,7 @@ Total: 191 (runtime: 66, contract: 125)
165
173
  - `tests/node-diagnostics-contract.test.js`
166
174
  - `tests/node-edge-disconnect-contract.test.js`
167
175
  - `tests/node-footer-compact-contract.test.js`
176
+ - `tests/node-gallery-drop-contract.test.js`
168
177
  - `tests/node-generation-lock-contract.test.js`
169
178
  - `tests/node-layout-contract.test.js`
170
179
  - `tests/node-pending-recovery-contract.test.js`
@@ -184,6 +193,7 @@ Total: 191 (runtime: 66, contract: 125)
184
193
  - `tests/prompt-library-ui-contract.test.js`
185
194
  - `tests/prompt-studio-docs-contract.test.js`
186
195
  - `tests/prompt-studio-ui-contract.test.js`
196
+ - `tests/provider-ui-polish-contract.test.js`
187
197
  - `tests/server-fallback-contract.test.js`
188
198
  - `tests/server.test.js`
189
199
  - `tests/session-conflict.test.js`
@@ -1,3 +1,6 @@
1
+ import { config } from "../config.js";
2
+ const MAX_AGENT_VARIANT_COUNT = Math.max(1, Math.trunc(Number(config.limits.maxGeneratedImages) || 24));
3
+ const MAX_AGENT_PARALLELISM = Math.max(1, Math.trunc(Number(config.limits.maxParallel) || 24));
1
4
  const COMMAND_ALIASES = {
2
5
  ask: "question",
3
6
  q: "question",
@@ -28,7 +31,7 @@ export function parseAgentSlashCommand(input) {
28
31
  return null;
29
32
  const rest = (match[2] ?? "").trim();
30
33
  const countCommands = ["variants", "generate", "parallelism"];
31
- const parsed = countCommands.includes(name) ? parseLeadingCount(rest) : { prompt: rest };
34
+ const parsed = countCommands.includes(name) ? parseLeadingCount(rest, name) : { prompt: rest };
32
35
  return {
33
36
  name,
34
37
  rawName,
@@ -41,9 +44,9 @@ export function formatAgentSlashHelp() {
41
44
  return [
42
45
  "Available Agent commands:",
43
46
  "/question <topic> - ask a planning question without generating images.",
44
- "/variants <1-8> <prompt> - force that many image variants.",
45
- "/generate <1-8> <prompt> - generate a bounded fanout from one request.",
46
- "/parallelism <1-8> <prompt> - cap concurrent tool calls for this turn.",
47
+ `/variants <1-${MAX_AGENT_VARIANT_COUNT}> <prompt> - force that many image variants.`,
48
+ `/generate <1-${MAX_AGENT_VARIANT_COUNT}> <prompt> - generate a bounded fanout from one request.`,
49
+ `/parallelism <1-${MAX_AGENT_PARALLELISM}> <prompt> - cap concurrent tool calls for this turn.`,
47
50
  ].join("\n");
48
51
  }
49
52
  export function formatAgentQuestionReply(prompt) {
@@ -53,12 +56,13 @@ export function formatAgentQuestionReply(prompt) {
53
56
  }
54
57
  return topic;
55
58
  }
56
- function parseLeadingCount(value) {
59
+ function parseLeadingCount(value, name) {
57
60
  const match = /^(\d+)(?:\s+([\s\S]*))?$/.exec(value);
58
61
  if (!match)
59
62
  return { prompt: value };
63
+ const max = name === "parallelism" ? MAX_AGENT_PARALLELISM : MAX_AGENT_VARIANT_COUNT;
60
64
  return {
61
- value: Math.max(1, Math.min(8, Number(match[1]))),
65
+ value: Math.max(1, Math.min(max, Number(match[1]))),
62
66
  prompt: (match[2] ?? "").trim(),
63
67
  };
64
68
  }
@@ -1,25 +1,35 @@
1
- const HARD_MAX_VARIANTS = 8;
1
+ import { config } from "../config.js";
2
+ const HARD_MAX_VARIANTS = Math.max(1, Math.trunc(Number(config.limits.maxGeneratedImages) || 24));
2
3
  const AMBIGUOUS_MULTI_VARIANTS = 3;
3
4
  const KOREAN_COUNT_WORDS = [
4
- [/(?:한|하나|1)\s*(?:장|개|가지|컷|시안|버전)/u, 1],
5
- [/(?:두|둘|2)\s*(?:장|개|가지|컷|시안|버전)/u, 2],
6
- [/(?:세|셋|3)\s*(?:장|개|가지|컷|시안|버전)/u, 3],
7
- [/(?:네|넷|4)\s*(?:장|개|가지|컷|시안|버전)/u, 4],
8
- [/(?:다섯|5)\s*(?:장|개|가지|컷|시안|버전)/u, 5],
9
- [/(?:여섯|6)\s*(?:장|개|가지|컷|시안|버전)/u, 6],
10
- [/(?:일곱|7)\s*(?:장|개|가지|컷|시안|버전)/u, 7],
11
- [/(?:여덟|8)\s*(?:장|개|가지|컷|시안|버전)/u, 8],
5
+ [/(?:스물네|스물\s*네|이십사)\s*(?:장|개|가지|컷|시안|버전)/u, 24],
6
+ [/(?:열두|열\s*두|십이)\s*(?:장|개|가지|컷|시안|버전)/u, 12],
7
+ [/(?:열|열\s*개)\s*(?:장|개|가지|컷|시안|버전)?/u, 10],
8
+ [/(?:한|하나)\s*(?:장|개|가지|컷|시안|버전)/u, 1],
9
+ [/(?:두|둘)\s*(?:장|개|가지|컷|시안|버전)/u, 2],
10
+ [/(?:세|셋)\s*(?:장|개|가지|컷|시안|버전)/u, 3],
11
+ [/(?:네|넷)\s*(?:장|개|가지|컷|시안|버전)/u, 4],
12
+ [/(?:다섯)\s*(?:장|개|가지|컷|시안|버전)/u, 5],
13
+ [/(?:여섯)\s*(?:장|개|가지|컷|시안|버전)/u, 6],
14
+ [/(?:일곱)\s*(?:장|개|가지|컷|시안|버전)/u, 7],
15
+ [/(?:여덟)\s*(?:장|개|가지|컷|시안|버전)/u, 8],
16
+ [/(?:아홉)\s*(?:장|개|가지|컷|시안|버전)/u, 9],
12
17
  ];
13
18
  const ENGLISH_COUNT_WORDS = [
14
- [/\b(?:one|1)\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 1],
15
- [/\b(?:two|2)\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 2],
16
- [/\b(?:three|3)\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 3],
17
- [/\b(?:four|4)\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 4],
18
- [/\b(?:five|5)\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 5],
19
- [/\b(?:six|6)\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 6],
20
- [/\b(?:seven|7)\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 7],
21
- [/\b(?:eight|8)\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 8],
19
+ [/\btwenty[-\s]?four\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 24],
20
+ [/\btwelve\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 12],
21
+ [/\bten\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 10],
22
+ [/\bone\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 1],
23
+ [/\btwo\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 2],
24
+ [/\bthree\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 3],
25
+ [/\bfour\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 4],
26
+ [/\bfive\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 5],
27
+ [/\bsix\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 6],
28
+ [/\bseven\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 7],
29
+ [/\beight\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 8],
30
+ [/\bnine\s*(?:image|variant|version|option|candidate|shot|render)?s?\b/iu, 9],
22
31
  ];
32
+ const NUMERIC_COUNT_PATTERN = /(?:^|\b)(\d{1,3})\s*(?:장|개|가지|컷|시안|버전|image|images|variant|variants|version|versions|option|options|candidate|candidates|shot|shots|render|renders)(?:\b|$)/iu;
23
33
  export function deriveAgentGenerationPlan({ prompt, settings, command = null }) {
24
34
  if (command?.name === "question" || command?.name === "help") {
25
35
  return {
@@ -32,6 +42,7 @@ export function deriveAgentGenerationPlan({ prompt, settings, command = null })
32
42
  reason: command.name === "help" ? "Slash help answered without image generation." : "Question command answered without image generation.",
33
43
  command: command.name,
34
44
  assistantText: null,
45
+ sourceImagePolicy: null,
35
46
  };
36
47
  }
37
48
  if (isVideoIntent(prompt)) {
@@ -45,6 +56,7 @@ export function deriveAgentGenerationPlan({ prompt, settings, command = null })
45
56
  reason: "Video generation detected from prompt keywords.",
46
57
  command: command?.name ?? null,
47
58
  assistantText: null,
59
+ sourceImagePolicy: "auto",
48
60
  };
49
61
  }
50
62
  const variantDecision = decideVariantCount(prompt, settings, command);
@@ -60,18 +72,41 @@ export function deriveAgentGenerationPlan({ prompt, settings, command = null })
60
72
  reason: variantDecision.reason,
61
73
  command: command?.name ?? null,
62
74
  assistantText: null,
75
+ sourceImagePolicy: inferSourceImagePolicy(prompt, prompts.length > 1 ? "fanout" : "single"),
63
76
  };
64
77
  }
65
78
  export function normalizeAgentGenerationPlan(prompt, value, settings) {
66
79
  const input = value && typeof value === "object" ? value : {};
67
80
  const prompts = cleanPromptArray(input.prompts);
81
+ // errors/question modes legitimately carry no generation prompts — they
82
+ // must be resolved before the empty-prompts fallback re-derives a regex plan.
83
+ if (input.mode === "errors" || input.mode === "question") {
84
+ return {
85
+ mode: input.mode,
86
+ prompts: [],
87
+ requestedVariants: 0,
88
+ plannedVariants: 0,
89
+ plannedParallelism: 0,
90
+ source: cleanPlanSource(input.source),
91
+ reason: cleanReason(input.reason, input.mode === "errors" ? "User asked about recent generation failures." : "User asked a question answered without generation."),
92
+ command: cleanCommandName(input.command),
93
+ assistantText: typeof input.assistantText === "string" ? input.assistantText : null,
94
+ videoParams: null,
95
+ sourceImagePolicy: null,
96
+ };
97
+ }
68
98
  if (prompts.length === 0)
69
99
  return deriveAgentGenerationPlan({ prompt, settings, command: cleanCommand(input.command) });
70
100
  const plannedVariants = cleanCount(input.plannedVariants, prompts.length, 0, HARD_MAX_VARIANTS);
71
101
  const requestedParallelism = cleanCount(input.plannedParallelism, settings.parallelism, 1, HARD_MAX_VARIANTS);
72
102
  const plannedParallelism = resolvePlannedParallelism({ ...settings, parallelism: requestedParallelism }, plannedVariants, null);
103
+ const mode = input.mode === "question"
104
+ ? "question"
105
+ : input.mode === "video"
106
+ ? "video"
107
+ : prompts.length > 1 ? "fanout" : "single";
73
108
  return {
74
- mode: input.mode === "question" ? "question" : input.mode === "video" ? "video" : prompts.length > 1 ? "fanout" : "single",
109
+ mode,
75
110
  prompts,
76
111
  requestedVariants: cleanCount(input.requestedVariants, plannedVariants, 0, HARD_MAX_VARIANTS),
77
112
  plannedVariants,
@@ -80,8 +115,24 @@ export function normalizeAgentGenerationPlan(prompt, value, settings) {
80
115
  reason: cleanReason(input.reason, prompts.length > 1 ? "Stored fanout plan." : "Stored single-image plan."),
81
116
  command: cleanCommandName(input.command),
82
117
  assistantText: typeof input.assistantText === "string" ? input.assistantText : null,
118
+ videoParams: mode === "video" ? cleanVideoParams(input.videoParams) : null,
119
+ sourceImagePolicy: cleanSourceImagePolicy(input.sourceImagePolicy) ?? inferSourceImagePolicy(prompt, mode),
83
120
  };
84
121
  }
122
+ function inferSourceImagePolicy(prompt, mode) {
123
+ if (mode === "question" || mode === "errors")
124
+ return null;
125
+ if (mode === "video")
126
+ return "auto";
127
+ const text = prompt.trim();
128
+ if (/(?:i2i\s*말고|image-to-image\s*말고|새로운\s*방식|새로|별도|독립|from\s+scratch|new\s+image|fresh\s+image|without\s+(?:reference|refs?)|no\s+(?:reference|refs?))/iu.test(text)) {
129
+ return "none";
130
+ }
131
+ if (/(?:이\s*이미지|현재\s*이미지|방금\s*(?:그거|그\s*이미지|이미지)|참조|레퍼런스|reference|refs?|i2i|image-to-image|수정|편집|바꿔|변형|유지(?:해서)?|그걸|그거)/iu.test(text)) {
132
+ return "current";
133
+ }
134
+ return "none";
135
+ }
85
136
  function decideVariantCount(prompt, settings, command) {
86
137
  if (command?.name === "variants" || command?.name === "generate") {
87
138
  const count = clampCount(command.value ?? AMBIGUOUS_MULTI_VARIANTS, HARD_MAX_VARIANTS);
@@ -114,6 +165,11 @@ function decideVariantCount(prompt, settings, command) {
114
165
  }
115
166
  function inferRequestedVariantCount(prompt) {
116
167
  const text = prompt.trim();
168
+ const numericMatch = NUMERIC_COUNT_PATTERN.exec(text);
169
+ if (numericMatch) {
170
+ const count = Math.max(1, Math.trunc(Number.parseInt(numericMatch[1] ?? "", 10) || 1));
171
+ return { count, reason: `User request explicitly implies ${count} variant${count === 1 ? "" : "s"}.` };
172
+ }
117
173
  for (const [pattern, count] of [...KOREAN_COUNT_WORDS, ...ENGLISH_COUNT_WORDS]) {
118
174
  if (pattern.test(text)) {
119
175
  return { count, reason: `User request explicitly implies ${count} variant${count === 1 ? "" : "s"}.` };
@@ -156,10 +212,31 @@ function cleanPlanSource(value) {
156
212
  value === "auto-request" ||
157
213
  value === "manual-settings" ||
158
214
  value === "slash-command" ||
159
- value === "question-command")
215
+ value === "question-command" ||
216
+ value === "llm-planner")
160
217
  return value;
161
218
  return "auto-default";
162
219
  }
220
+ function cleanSourceImagePolicy(value) {
221
+ if (value === "auto" || value === "none" || value === "current")
222
+ return value;
223
+ return null;
224
+ }
225
+ export function cleanVideoParams(value) {
226
+ if (!value || typeof value !== "object")
227
+ return null;
228
+ const input = value;
229
+ const params = {};
230
+ const duration = typeof input.duration === "number" ? input.duration : Number(input.duration);
231
+ if (Number.isFinite(duration))
232
+ params.duration = Math.max(1, Math.min(15, Math.round(duration)));
233
+ if (input.resolution === "480p" || input.resolution === "720p")
234
+ params.resolution = input.resolution;
235
+ if (typeof input.aspectRatio === "string" && /^(auto|16:9|9:16|4:3|3:4|3:2|2:3|1:1)$/.test(input.aspectRatio)) {
236
+ params.aspectRatio = input.aspectRatio;
237
+ }
238
+ return Object.keys(params).length > 0 ? params : null;
239
+ }
163
240
  function cleanCommandName(value) {
164
241
  if (value === "question" ||
165
242
  value === "help" ||