ima2-gen 2.0.1 → 2.0.3

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 (100) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +10 -1
  3. package/bin/commands/backfillThumbs.js +6 -0
  4. package/bin/commands/gen.js +13 -5
  5. package/bin/commands/multimode.js +7 -5
  6. package/bin/commands/node.js +2 -1
  7. package/bin/ima2.js +14 -10
  8. package/config.js +8 -1
  9. package/docs/API.md +133 -9
  10. package/docs/CLI.md +6 -5
  11. package/docs/FAQ.ko.md +16 -0
  12. package/docs/FAQ.md +30 -0
  13. package/docs/README.ko.md +7 -3
  14. package/docs/migration/runtime-test-inventory.md +25 -1
  15. package/lib/agentCommandParser.js +10 -6
  16. package/lib/agentGenerationPlanner.js +96 -19
  17. package/lib/agentImageVideoGen.js +281 -0
  18. package/lib/agentPlannerModel.js +172 -0
  19. package/lib/agentQuestionResponder.js +9 -3
  20. package/lib/agentQueueStore.js +42 -0
  21. package/lib/agentQueueWorker.js +57 -7
  22. package/lib/agentRuntime.js +103 -274
  23. package/lib/agentSettings.js +9 -6
  24. package/lib/agentToolManifest.js +90 -0
  25. package/lib/agentTypes.js +1 -0
  26. package/lib/agyCli.js +31 -0
  27. package/lib/agyImageAdapter.js +104 -12
  28. package/lib/capabilities.js +7 -5
  29. package/lib/configKeys.js +6 -0
  30. package/lib/errorClassify.js +8 -7
  31. package/lib/eventBus.js +71 -0
  32. package/lib/geminiApiImageAdapter.js +24 -21
  33. package/lib/generationErrors.js +3 -1
  34. package/lib/generationRequestLog.js +25 -0
  35. package/lib/grokImageAdapter.js +76 -144
  36. package/lib/grokImageCore.js +153 -0
  37. package/lib/grokMultimodeAdapter.js +7 -4
  38. package/lib/grokVideoAdapter.js +3 -2
  39. package/lib/grokVideoCanvas.js +13 -0
  40. package/lib/grokVideoPlannerPrompt.js +52 -12
  41. package/lib/historyList.js +1 -0
  42. package/lib/inflight.js +54 -17
  43. package/lib/multimodeHelpers.js +11 -0
  44. package/lib/nodeHelpers.js +59 -0
  45. package/lib/oauthProxy/generators.js +6 -5
  46. package/lib/oauthProxy/prompts.js +60 -41
  47. package/lib/oauthProxy/streams.js +1 -1
  48. package/lib/promptBuilder/systemPrompt.js +2 -5
  49. package/lib/promptSafetyPolicy.js +1 -5
  50. package/lib/responsesDoctor.js +3 -3
  51. package/lib/responsesFallback.js +29 -15
  52. package/lib/responsesImageAdapter.js +11 -7
  53. package/lib/routeHelpers.js +44 -0
  54. package/lib/ssePublish.js +12 -0
  55. package/lib/storyboardPrefix.js +28 -0
  56. package/lib/thumbBackfill.js +16 -5
  57. package/node_modules/qs/CHANGELOG.md +10 -0
  58. package/node_modules/qs/README.md +1 -1
  59. package/node_modules/qs/dist/qs.js +15 -15
  60. package/node_modules/qs/eslint.config.mjs +1 -0
  61. package/node_modules/qs/lib/parse.js +52 -22
  62. package/node_modules/qs/lib/stringify.js +11 -4
  63. package/node_modules/qs/package.json +2 -2
  64. package/node_modules/qs/test/parse.js +49 -0
  65. package/node_modules/qs/test/stringify.js +129 -0
  66. package/package.json +4 -1
  67. package/routes/agent.js +19 -2
  68. package/routes/agy.js +48 -0
  69. package/routes/auth.js +6 -2
  70. package/routes/edit.js +7 -1
  71. package/routes/events.js +78 -0
  72. package/routes/generate.js +111 -128
  73. package/routes/generationRequestLog.js +16 -0
  74. package/routes/index.js +6 -0
  75. package/routes/keys.js +22 -2
  76. package/routes/multimode.js +100 -57
  77. package/routes/nodes.js +64 -108
  78. package/routes/video.js +100 -17
  79. package/skills/ima2/SKILL.md +98 -21
  80. package/ui/dist/.vite/manifest.json +22 -12
  81. package/ui/dist/assets/AgentWorkspace-qEMrATBP.js +3 -0
  82. package/ui/dist/assets/{CardNewsWorkspace-Dqyc1WZ1.js → CardNewsWorkspace-DoJnVHmn.js} +1 -1
  83. package/ui/dist/assets/GenerationRequestLogPanel-BDWRRLNV.js +1 -0
  84. package/ui/dist/assets/{NodeCanvas-ChEXzQbb.js → NodeCanvas-DIMo45sp.js} +3 -3
  85. package/ui/dist/assets/{PromptBuilderPanel-B95ZufnR.js → PromptBuilderPanel-BF3lcSJD.js} +2 -2
  86. package/ui/dist/assets/{PromptImportDialog-DGOwFQET.js → PromptImportDialog-Cy6ZymKG.js} +2 -2
  87. package/ui/dist/assets/{PromptImportDiscoverySection-CgvdnR49.js → PromptImportDiscoverySection-CzuW-8P2.js} +1 -1
  88. package/ui/dist/assets/PromptImportFolderSection-DP5ywnsv.js +1 -0
  89. package/ui/dist/assets/{PromptLibraryPanel-B9kndPw1.js → PromptLibraryPanel-CAsKr7CV.js} +2 -2
  90. package/ui/dist/assets/SettingsWorkspace-_PdPVsxi.js +1 -0
  91. package/ui/dist/assets/index-BUinlX2n.js +4 -0
  92. package/ui/dist/assets/index-CX3fge8X.css +1 -0
  93. package/ui/dist/assets/index-ygo6nfqx.js +23 -0
  94. package/ui/dist/index.html +2 -2
  95. package/ui/dist/assets/AgentWorkspace-CYv84Rus.js +0 -3
  96. package/ui/dist/assets/PromptImportFolderSection-CfUye9J8.js +0 -1
  97. package/ui/dist/assets/SettingsWorkspace-B3tgLrmF.js +0 -1
  98. package/ui/dist/assets/index-BhcvL0g-.js +0 -1
  99. package/ui/dist/assets/index-BtK3YhJc.js +0 -39
  100. package/ui/dist/assets/index-ClOLOjnA.css +0 -1
@@ -0,0 +1,90 @@
1
+ import { AGENT_ALLOWED_TOOLS } from "./agentTypes.js";
2
+ // Single source of truth for the agent tool surface. Consumed by the LLM
3
+ // planner developer prompt, /api/agent/tools, and capabilities.agentMode.
4
+ export const AGENT_TOOL_MANIFEST = [
5
+ {
6
+ name: "ima2.get_image_context",
7
+ description: "Load the session image context manifest (previous images, current image, locks). Runs automatically before image generation.",
8
+ parameters: {
9
+ type: "object",
10
+ properties: {},
11
+ additionalProperties: false,
12
+ },
13
+ },
14
+ {
15
+ name: "ima2.web_search",
16
+ description: "Search the web for factual visual references before generating. Only available when web search is enabled for the session.",
17
+ parameters: {
18
+ type: "object",
19
+ properties: {
20
+ query: { type: "string", description: "Search query for factual visual accuracy." },
21
+ },
22
+ required: ["query"],
23
+ additionalProperties: false,
24
+ },
25
+ },
26
+ {
27
+ name: "ima2.generate_image",
28
+ description: "Generate one or more images. Supports fanout: provide one prompt per variant.",
29
+ parameters: {
30
+ type: "object",
31
+ properties: {
32
+ prompts: {
33
+ type: "array",
34
+ items: { type: "string" },
35
+ description: "One generation prompt per planned variant (1 to the configured image limit).",
36
+ },
37
+ plannedVariants: { type: "integer", minimum: 1, description: "Number of images to generate." },
38
+ plannedParallelism: { type: "integer", minimum: 1, description: "Concurrent generation calls." },
39
+ sourceImagePolicy: {
40
+ type: "string",
41
+ enum: ["auto", "none", "current"],
42
+ description: "none creates a fresh image and ignores the current session image; current uses the current session image as edit/reference input; auto lets the runtime choose.",
43
+ },
44
+ },
45
+ required: ["prompts"],
46
+ additionalProperties: false,
47
+ },
48
+ },
49
+ {
50
+ name: "ima2.generate_video",
51
+ description: "Generate a single video with Grok Imagine. If the session has a last image, it is used as the image-to-video source automatically.",
52
+ parameters: {
53
+ type: "object",
54
+ properties: {
55
+ prompt: { type: "string", description: "Video prompt describing visual flow and motion." },
56
+ duration: { type: "integer", minimum: 1, maximum: 15, description: "Video duration in seconds. Default 5." },
57
+ resolution: { type: "string", enum: ["480p", "720p"], description: "Output resolution. Default 480p." },
58
+ aspectRatio: {
59
+ type: "string",
60
+ enum: ["auto", "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"],
61
+ description: "Output aspect ratio. Default auto.",
62
+ },
63
+ },
64
+ required: ["prompt"],
65
+ additionalProperties: false,
66
+ },
67
+ },
68
+ {
69
+ name: "ima2.get_generation_errors",
70
+ description: "Read-only lookup of the session's recent generation failures (failed queue jobs and error turns). Use when the user asks why a generation failed.",
71
+ parameters: {
72
+ type: "object",
73
+ properties: {
74
+ limit: { type: "integer", minimum: 1, maximum: 20, description: "Maximum error records to return. Default 10." },
75
+ },
76
+ additionalProperties: false,
77
+ },
78
+ },
79
+ ];
80
+ const MANIFEST_NAMES = new Set(AGENT_TOOL_MANIFEST.map((entry) => entry.name));
81
+ for (const tool of AGENT_ALLOWED_TOOLS) {
82
+ if (!MANIFEST_NAMES.has(tool)) {
83
+ throw new Error(`Agent tool manifest is missing an entry for: ${tool}`);
84
+ }
85
+ }
86
+ export function formatToolManifestForPrompt() {
87
+ return AGENT_TOOL_MANIFEST
88
+ .map((entry) => `- ${entry.name}: ${entry.description}\n parameters: ${JSON.stringify(entry.parameters)}`)
89
+ .join("\n");
90
+ }
package/lib/agentTypes.js CHANGED
@@ -3,4 +3,5 @@ export const AGENT_ALLOWED_TOOLS = [
3
3
  "ima2.web_search",
4
4
  "ima2.generate_image",
5
5
  "ima2.generate_video",
6
+ "ima2.get_generation_errors",
6
7
  ];
package/lib/agyCli.js ADDED
@@ -0,0 +1,31 @@
1
+ import { existsSync } from "node:fs";
2
+ import { delimiter, dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ export function agyCommandName(platform = process.platform) {
5
+ return platform === "win32" ? "agy.cmd" : "agy";
6
+ }
7
+ export function agyLocalBinCandidates(home = homedir(), platform = process.platform) {
8
+ const command = agyCommandName(platform);
9
+ return [
10
+ join(home, ".local", "bin", command),
11
+ join(home, ".npm-global", "bin", command),
12
+ ];
13
+ }
14
+ export function resolveAgyBin(env = process.env, home = homedir(), platform = process.platform) {
15
+ if (env.IMA2_AGY_BIN) {
16
+ if (!existsSync(env.IMA2_AGY_BIN)) {
17
+ console.warn(`[ima2-agy] IMA2_AGY_BIN is set to "${env.IMA2_AGY_BIN}" but the file does not exist. Falling back to PATH lookup.`);
18
+ }
19
+ else {
20
+ return env.IMA2_AGY_BIN;
21
+ }
22
+ }
23
+ return agyLocalBinCandidates(home, platform).find((candidate) => existsSync(candidate))
24
+ ?? agyCommandName(platform);
25
+ }
26
+ export function buildAgyPathEnv(env = process.env, agyBin = resolveAgyBin(env)) {
27
+ const basePath = env.PATH || "";
28
+ if (agyBin === agyCommandName() || agyBin === "agy")
29
+ return basePath;
30
+ return `${dirname(agyBin)}${delimiter}${basePath}`;
31
+ }
@@ -1,9 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
- import { readFile, rm, stat, writeFile, mkdir } from "node:fs/promises";
3
- import { extname, join, resolve } from "node:path";
2
+ import { readFile, readdir, rm, stat, writeFile, mkdir } from "node:fs/promises";
3
+ import { extname, dirname, join, resolve } from "node:path";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { randomBytes } from "node:crypto";
6
+ import { buildAgyPathEnv, resolveAgyBin } from "./agyCli.js";
6
7
  import { logEvent } from "./logger.js";
8
+ import { SAFETY_INTENT_POLICY } from "./promptSafetyPolicy.js";
7
9
  import { detectImageMimeFromB64 } from "./refs.js";
8
10
  const AGY_TIMEOUT_MS = 360_000;
9
11
  const AGY_OUTPUT_RESOLUTION = "1024x1024";
@@ -19,7 +21,10 @@ function buildAgyPrompt(userPrompt, referencePaths) {
19
21
  ? JSON.stringify(referencePaths)
20
22
  : "[]";
21
23
  return [
22
- "Please generate one image by calling the tool default_api:generate_image once.",
24
+ "You are an image generation assistant inside a professional creative tool used by designers and artists. Users create content for legitimate creative, commercial, or educational purposes.",
25
+ SAFETY_INTENT_POLICY,
26
+ "",
27
+ "Generate one image by calling the tool default_api:generate_image once.",
23
28
  "After the tool finishes, print one machine-readable result line so ima2-gen can copy the artifact.",
24
29
  "",
25
30
  "Tool parameters:",
@@ -30,17 +35,27 @@ function buildAgyPrompt(userPrompt, referencePaths) {
30
35
  ' toolAction: "Generating ima2 image"',
31
36
  "",
32
37
  `Reference count: ${referencePaths.length}. The output resolution is fixed at ${AGY_OUTPUT_RESOLUTION}.`,
38
+ "",
39
+ "PROMPT HANDLING:",
40
+ "- Preserve the user's prompt as-is for compliant requests. Do not translate, restyle, or soften benign adult clothing requests such as swimwear.",
41
+ "- If the user wrote in Korean, Japanese, or any non-English language, keep visible text and dialogue in that language.",
42
+ "- Do not sexualize, intensify exposure, or add sexualized/fetish framing to benign swimwear, sportswear, or catalog fashion prompts.",
43
+ "- Do not copy safety labels, refusal reasons, or negative safety constraints into the final image prompt.",
44
+ "- If the request includes minors, underage-looking subjects, explicit sexual activity, or fetish framing, do not rewrite it as a benign allowed prompt.",
45
+ "- Content moderation is still handled downstream by the provider.",
46
+ "",
47
+ "OUTPUT:",
33
48
  "If generation succeeds, print: RESULT|<absolute_artifact_path>|<file_extension>",
34
49
  "If generation fails, print: ERROR|<concise error message>",
35
50
  ].join("\n");
36
51
  }
37
52
  function parseAgyOutput(stdout) {
38
- const lines = stdout.trim().split("\n").filter((l) => l.trim().length > 0);
53
+ const lines = stdout.replace(/\r/g, "").trim().split("\n").filter((l) => l.trim().length > 0);
39
54
  const resultLine = lines.find((l) => l.startsWith("RESULT|"));
40
55
  if (resultLine) {
41
56
  const parts = resultLine.split("|");
42
57
  if (parts.length >= 3) {
43
- return { artifactPath: parts[1], ext: parts[2] };
58
+ return { artifactPath: parts[1].trim(), ext: parts[2].trim() };
44
59
  }
45
60
  throw agyError(`Malformed RESULT line: ${resultLine}`, 502, "AGY_MALFORMED_RESULT");
46
61
  }
@@ -63,21 +78,57 @@ function parseAgyOutput(stdout) {
63
78
  const ext = p.split(".").pop() || "png";
64
79
  return { artifactPath: p, ext };
65
80
  }
66
- const normalizedStdout = stdout.replace(/\\/g, "/");
67
- const pathMatch = normalizedStdout.match(/\/[^\s"']+\/(brain|artifacts)\/[^\s"']+\.(png|jpg|jpeg|webp)/i);
81
+ const normalizedStdout = stdout.replace(/\r/g, "").replace(/\\/g, "/");
82
+ const pathMatch = normalizedStdout.match(/(?:[A-Za-z]:)?\/[^\s"']+\/(brain|artifacts|\.gemini)\/[^\s"']+\.(png|jpg|jpeg|webp)/i);
68
83
  if (pathMatch) {
69
- const artifactPath = process.platform === "win32" ? pathMatch[0].replace(/\//g, "\\") : pathMatch[0];
84
+ const matched = pathMatch[0];
85
+ const artifactPath = process.platform === "win32" ? matched.replace(/\//g, "\\") : matched;
70
86
  const ext = extname(artifactPath).slice(1) || "png";
71
87
  return { artifactPath, ext };
72
88
  }
73
89
  throw agyError(`Could not parse artifact path from agy output (${stdout.length} chars): ${stdout.slice(0, 200)}`, 502, "AGY_PARSE_FAILED");
74
90
  }
91
+ export async function findRecentAgyArtifact(sinceMs, rootOverrides) {
92
+ const roots = rootOverrides ?? [
93
+ join(homedir(), ".gemini", "antigravity-cli", "brain"),
94
+ join(homedir(), ".gemini"),
95
+ ];
96
+ const candidates = [];
97
+ const seen = new Set();
98
+ async function walk(dir, depth) {
99
+ if (depth > 5 || seen.has(dir))
100
+ return;
101
+ seen.add(dir);
102
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
103
+ for (const entry of entries) {
104
+ const p = join(dir, entry.name);
105
+ if (!entry.isSymbolicLink() && entry.isDirectory()) {
106
+ await walk(p, depth + 1);
107
+ continue;
108
+ }
109
+ if (!/^ima2_generated.*\.(png|jpg|jpeg|webp)$/i.test(entry.name)) {
110
+ continue;
111
+ }
112
+ const s = await stat(p).catch(() => null);
113
+ if (!s)
114
+ continue;
115
+ if (s.mtimeMs >= sinceMs - 5_000) {
116
+ candidates.push({ path: p, mtimeMs: s.mtimeMs });
117
+ }
118
+ }
119
+ }
120
+ for (const root of roots) {
121
+ await walk(root, 0);
122
+ }
123
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
124
+ return candidates[0]?.path ?? null;
125
+ }
75
126
  function spawnAgy(prompt, signal) {
76
127
  return new Promise((resolve, reject) => {
77
- const child = spawn("agy", ["-p", "-"], {
128
+ const child = spawn(resolveAgyBin(), ["-p", "-"], {
78
129
  stdio: ["pipe", "pipe", "pipe"],
79
130
  env: {
80
- PATH: process.env.PATH,
131
+ PATH: buildAgyPathEnv(),
81
132
  HOME: process.env.HOME,
82
133
  USERPROFILE: process.env.USERPROFILE,
83
134
  TMPDIR: process.env.TMPDIR,
@@ -105,7 +156,11 @@ function spawnAgy(prompt, signal) {
105
156
  return;
106
157
  settled = true;
107
158
  clearTimeout(timer);
108
- reject(agyError(`Agy process error: ${err.message}`, 502, "AGY_PROCESS_ERROR"));
159
+ const bin = resolveAgyBin();
160
+ const hint = err.code === "ENOENT"
161
+ ? `. "${bin}" was not found — install agy or set IMA2_AGY_BIN=/absolute/path/to/agy`
162
+ : "";
163
+ reject(agyError(`Agy process error: ${err.message}${hint}`, 502, "AGY_PROCESS_ERROR"));
109
164
  });
110
165
  child.on("close", (code) => {
111
166
  if (settled)
@@ -167,6 +222,17 @@ async function writeRefsToTempFiles(refs) {
167
222
  },
168
223
  };
169
224
  }
225
+ async function cleanupAgyArtifact(artifactPath) {
226
+ try {
227
+ await rm(artifactPath, { force: true }).catch(() => { });
228
+ const dir = dirname(artifactPath);
229
+ const entries = await readdir(dir).catch(() => null);
230
+ if (entries && entries.length === 0) {
231
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
232
+ }
233
+ }
234
+ catch { /* best-effort */ }
235
+ }
170
236
  export async function generateViaAgy(prompt, options = {}) {
171
237
  const refDetails = (options.references || []).slice(0, 3);
172
238
  const { paths: refPaths, cleanup } = await writeRefsToTempFiles(refDetails);
@@ -178,6 +244,7 @@ export async function generateViaAgy(prompt, options = {}) {
178
244
  refs: refPaths.length,
179
245
  });
180
246
  try {
247
+ const generationStartedAtMs = Date.now();
181
248
  const { stdout, stderr } = await spawnAgy(agyPrompt, options.signal);
182
249
  if (stderr && stderr.trim().length > 0) {
183
250
  logEvent("agy", "generate:stderr", {
@@ -186,7 +253,27 @@ export async function generateViaAgy(prompt, options = {}) {
186
253
  stderrPreview: stderr.slice(0, 200),
187
254
  });
188
255
  }
189
- const { artifactPath } = parseAgyOutput(stdout);
256
+ const agyCombinedOutput = [stdout, stderr].filter(Boolean).join("\n");
257
+ let artifactPath;
258
+ try {
259
+ artifactPath = parseAgyOutput(agyCombinedOutput).artifactPath;
260
+ }
261
+ catch (err) {
262
+ if (err?.code !== "AGY_PARSE_FAILED") {
263
+ throw err;
264
+ }
265
+ const fallbackPath = await findRecentAgyArtifact(generationStartedAtMs);
266
+ if (!fallbackPath) {
267
+ throw err;
268
+ }
269
+ logEvent("agy", "generate:fallback_artifact_found", {
270
+ requestId: options.requestId,
271
+ artifactPath: fallbackPath,
272
+ stdoutChars: stdout.length,
273
+ stderrChars: stderr.length,
274
+ });
275
+ artifactPath = fallbackPath;
276
+ }
190
277
  // Validate artifact path is within allowed directories
191
278
  const resolvedPath = resolve(artifactPath);
192
279
  const allowedPrefixes = [
@@ -218,6 +305,7 @@ export async function generateViaAgy(prompt, options = {}) {
218
305
  mime,
219
306
  fileBytes: buffer.length,
220
307
  });
308
+ await cleanupAgyArtifact(resolvedPath);
221
309
  return {
222
310
  b64,
223
311
  revisedPrompt: prompt,
@@ -226,6 +314,10 @@ export async function generateViaAgy(prompt, options = {}) {
226
314
  mime,
227
315
  };
228
316
  }
317
+ catch (err) {
318
+ logEvent("agy", "generate:failed_cleanup", { requestId: options.requestId });
319
+ throw err;
320
+ }
229
321
  finally {
230
322
  await cleanup();
231
323
  }
@@ -1,7 +1,8 @@
1
1
  import { config as runtimeConfigDefault } from "../config.js";
2
+ import { AGENT_ALLOWED_TOOLS } from "./agentTypes.js";
3
+ import { AGENT_TOOL_MANIFEST } from "./agentToolManifest.js";
2
4
  import { KEY_TO_ENV, WRITABLE_CONFIG_KEYS } from "./configKeys.js";
3
5
  import { DEFAULT_IMAGE_QUALITY, VALID_IMAGE_QUALITIES } from "./oauthNormalize.js";
4
- const MAX_GENERATED_IMAGES = 8;
5
6
  const VALID_MODES = ["auto", "direct"];
6
7
  const VALID_PROVIDERS = ["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api"];
7
8
  const AGENT_COMMANDS = [
@@ -79,11 +80,11 @@ export function buildIma2Capabilities({ appConfig = runtimeConfigDefault, packag
79
80
  },
80
81
  limits: {
81
82
  maxRefCount: appConfig.limits.maxRefCount,
82
- maxGeneratedImages: MAX_GENERATED_IMAGES,
83
+ maxGeneratedImages: appConfig.limits.maxGeneratedImages,
83
84
  maxParallel: {
84
85
  value: appConfig.limits.maxParallel,
85
- enforced: false,
86
- note: "advisory client-side queue guidance only; server-side semaphore is not enforced",
86
+ enforced: true,
87
+ note: "server-side inflight capacity guard uses this runtime limit",
87
88
  },
88
89
  },
89
90
  promptBuilder: {
@@ -96,7 +97,8 @@ export function buildIma2Capabilities({ appConfig = runtimeConfigDefault, packag
96
97
  agentMode: {
97
98
  available: true,
98
99
  route: "/api/agent/sessions",
99
- allowedTools: ["ima2.get_image_context", "ima2.web_search", "ima2.generate_image"],
100
+ allowedTools: [...AGENT_ALLOWED_TOOLS],
101
+ toolManifest: [...AGENT_TOOL_MANIFEST],
100
102
  finalArtifact: "image",
101
103
  uiOnly: true,
102
104
  cliCommand: null,
package/lib/configKeys.js CHANGED
@@ -10,6 +10,8 @@ export const WRITABLE_CONFIG_KEYS = new Set([
10
10
  "cardNewsPlanner.model",
11
11
  "cardNewsPlanner.timeoutMs",
12
12
  "cardNewsPlanner.deterministicFallback",
13
+ "agentPlanner.enabled",
14
+ "agentPlanner.timeoutMs",
13
15
  "grokProvider.plannerModel",
14
16
  "grokProvider.plannerTimeoutMs",
15
17
  "grokProvider.defaultImageModel",
@@ -25,6 +27,7 @@ export const WRITABLE_CONFIG_KEYS = new Set([
25
27
  "oauth.statusTimeoutMs",
26
28
  "oauth.restartDelayMs",
27
29
  "limits.maxRefCount",
30
+ "limits.maxGeneratedImages",
28
31
  "limits.maxParallel",
29
32
  "history.defaultPageSize",
30
33
  "history.maxPageCap",
@@ -44,9 +47,12 @@ export const KEY_TO_ENV = {
44
47
  "cardNewsPlanner.enabled": "IMA2_CARD_NEWS_PLANNER",
45
48
  "cardNewsPlanner.model": "IMA2_CARD_NEWS_PLANNER_MODEL",
46
49
  "cardNewsPlanner.timeoutMs": "IMA2_CARD_NEWS_PLANNER_TIMEOUT_MS",
50
+ "agentPlanner.enabled": "IMA2_AGENT_PLANNER_ENABLED",
51
+ "agentPlanner.timeoutMs": "IMA2_AGENT_PLANNER_TIMEOUT_MS",
47
52
  "grokProvider.plannerModel": "IMA2_GROK_PLANNER_MODEL",
48
53
  "grokProvider.plannerTimeoutMs": "IMA2_GROK_PLANNER_TIMEOUT_MS",
49
54
  "grokProvider.defaultImageModel": "IMA2_GROK_IMAGE_MODEL_DEFAULT",
55
+ "limits.maxGeneratedImages": "IMA2_MAX_GENERATED_IMAGES",
50
56
  "limits.maxParallel": "IMA2_MAX_PARALLEL",
51
57
  "limits.maxRefCount": "IMA2_MAX_REF_COUNT",
52
58
  "history.defaultPageSize": "IMA2_HISTORY_PAGE_SIZE",
@@ -29,13 +29,14 @@ export function classifyUpstreamErrorCode(code) {
29
29
  return "MODERATION_REFUSED";
30
30
  return "UNKNOWN";
31
31
  }
32
- /**
33
- * Classify an upstream error message into an ImaErrorCode.
34
- * Order matters: auth session expiry must beat generic "token" matches,
35
- * and moderation must beat generic 5xx.
36
- * @param {string | undefined | null} msg
37
- * @returns {ImaErrorCode}
38
- */
32
+ export function classifyModerationStage(msg) {
33
+ const s = String(msg || "").toLowerCase();
34
+ if (s.includes("request was rejected") || s.includes("prompt was rejected"))
35
+ return "input";
36
+ if (s.includes("image was filtered") || s.includes("generated image"))
37
+ return "output";
38
+ return "unknown";
39
+ }
39
40
  export function classifyUpstreamError(msg) {
40
41
  const s = String(msg || "").toLowerCase();
41
42
  if (!s)
@@ -0,0 +1,71 @@
1
+ import { EventEmitter } from "node:events";
2
+ /** Global replay window — sized for 7+ concurrent jobs (~15 events each) with reconnect headroom. */
3
+ export const RING_SIZE = 2000;
4
+ /** Align with /api/events connection cap — avoids MaxListenersExceededWarning under load. */
5
+ export const MAX_SSE_LISTENERS = 512;
6
+ const bus = new EventEmitter();
7
+ bus.setMaxListeners(MAX_SSE_LISTENERS);
8
+ let seq = 0;
9
+ const ring = [];
10
+ function omitLargeImageFields(data) {
11
+ let omitted = false;
12
+ const next = { ...data };
13
+ if (typeof next.image === "string" && next.image.length > 1000) {
14
+ delete next.image;
15
+ omitted = true;
16
+ }
17
+ if (Array.isArray(next.images)) {
18
+ const images = next.images.map((item) => {
19
+ if (!item || typeof item !== "object" || Array.isArray(item))
20
+ return item;
21
+ const imageItem = item;
22
+ if (typeof imageItem.image !== "string" || imageItem.image.length <= 1000)
23
+ return item;
24
+ const { image: _omit, ...rest } = imageItem;
25
+ omitted = true;
26
+ return { ...rest, _imageOmitted: true };
27
+ });
28
+ if (omitted)
29
+ next.images = images;
30
+ }
31
+ if (omitted)
32
+ next._imageOmitted = true;
33
+ return { data: omitted ? next : data, omitted };
34
+ }
35
+ function toRingEntry(entry) {
36
+ // Keep terminal/partial metadata replayable; omit multi-MB base64 from the ring.
37
+ const stripped = omitLargeImageFields(entry.data);
38
+ return stripped.omitted ? { ...entry, data: stripped.data } : entry;
39
+ }
40
+ export function publish(jobId, event, data) {
41
+ seq++;
42
+ const entry = { id: seq, jobId, event, data };
43
+ const ringEntry = toRingEntry(entry);
44
+ ring.push(ringEntry);
45
+ if (ring.length > RING_SIZE)
46
+ ring.shift();
47
+ bus.emit("event", entry);
48
+ }
49
+ export function subscribe(listener) {
50
+ bus.on("event", listener);
51
+ return () => bus.off("event", listener);
52
+ }
53
+ export function replayOldestId() {
54
+ return ring.length > 0 ? ring[0].id : null;
55
+ }
56
+ /** True when the ring has evicted events the client still expects from Last-Event-ID. */
57
+ export function hasReplayGap(lastEventId) {
58
+ if (lastEventId <= 0 || ring.length === 0)
59
+ return false;
60
+ const oldest = ring[0].id;
61
+ return lastEventId < oldest - 1;
62
+ }
63
+ export function replaySince(lastEventId) {
64
+ const idx = ring.findIndex(e => e.id > lastEventId);
65
+ return idx === -1 ? [] : ring.slice(idx);
66
+ }
67
+ export function _resetForTest() {
68
+ seq = 0;
69
+ ring.length = 0;
70
+ bus.removeAllListeners();
71
+ }
@@ -8,30 +8,30 @@ const MODEL_ID_MAP = {
8
8
  const GEMINI_TIMEOUT_MS = 120_000;
9
9
  function parseGeminiImageParams(size) {
10
10
  if (!size || size === "auto" || size === "1024x1024")
11
- return { aspectRatio: 1, imageSize: 0 };
11
+ return { aspectRatio: "1:1", imageSize: "1K" };
12
12
  const match = size.match(/^(\d+)x(\d+)$/);
13
13
  if (!match)
14
- return { aspectRatio: 1, imageSize: 0 };
14
+ return { aspectRatio: "1:1", imageSize: "1K" };
15
15
  const w = Number(match[1]);
16
16
  const h = Number(match[2]);
17
17
  const ratio = w / h;
18
18
  const ratioMap = [
19
- [1, 1], [2, 2 / 3], [3, 3 / 2], [4, 3 / 4], [5, 4 / 3],
20
- [6, 4 / 5], [7, 5 / 4], [8, 9 / 16], [9, 16 / 9], [10, 21 / 9],
21
- [11, 1 / 8], [12, 8], [13, 1 / 4], [14, 4],
19
+ ["1:1", 1], ["2:3", 2 / 3], ["3:2", 3 / 2], ["3:4", 3 / 4], ["4:3", 4 / 3],
20
+ ["4:5", 4 / 5], ["5:4", 5 / 4], ["9:16", 9 / 16], ["16:9", 16 / 9], ["21:9", 21 / 9],
21
+ ["1:8", 1 / 8], ["8:1", 8], ["1:4", 1 / 4], ["4:1", 4],
22
22
  ];
23
- let bestEnum = 1;
23
+ let bestLabel = "1:1";
24
24
  let bestDist = Infinity;
25
- for (const [enumVal, val] of ratioMap) {
25
+ for (const [label, val] of ratioMap) {
26
26
  const dist = Math.abs(ratio - val);
27
27
  if (dist < bestDist) {
28
28
  bestDist = dist;
29
- bestEnum = enumVal;
29
+ bestLabel = label;
30
30
  }
31
31
  }
32
32
  const maxDim = Math.max(w, h);
33
- const imageSize = maxDim <= 512 ? 1 : maxDim <= 1024 ? 2 : maxDim <= 2048 ? 3 : 4;
34
- return { aspectRatio: bestEnum, imageSize };
33
+ const imageSize = maxDim <= 512 ? "512" : maxDim <= 1024 ? "1K" : maxDim <= 2048 ? "2K" : "4K";
34
+ return { aspectRatio: bestLabel, imageSize };
35
35
  }
36
36
  function geminiApiError(message, status, code) {
37
37
  const err = new Error(message);
@@ -81,20 +81,23 @@ export async function generateViaGeminiApi(prompt, ctx, options = {}) {
81
81
  url = `https://generativelanguage.googleapis.com/v1beta/models/${apiModelId}:generateContent`;
82
82
  authHeaders = { "Content-Type": "application/json", "x-goog-api-key": apiKey };
83
83
  }
84
- const imageParams = parseGeminiImageParams(options.size);
85
- // NOTE: Vertex (aiplatform.googleapis.com) rejects the response_format field that the
86
- // direct Gemini API accepts, so the Vertex path can only request modalities — output
87
- // defaults to 1K/1:1 regardless of requested size. Direct API path honors aspect/size.
84
+ // size "auto" → omit image config entirely so the model decides ratio/size
85
+ const isAutoSize = !options.size || options.size === "auto";
86
+ const imageParams = isAutoSize ? null : parseGeminiImageParams(options.size);
87
+ // Vertex AI rejects responseFormat; it expects imageConfig directly under
88
+ // generationConfig (camelCase). Public API uses response_format.image (snake_case).
88
89
  const generationConfig = useVertex
89
- ? { responseModalities: ["TEXT", "IMAGE"] }
90
+ ? {
91
+ responseModalities: ["TEXT", "IMAGE"],
92
+ ...(imageParams
93
+ ? { imageConfig: { aspectRatio: imageParams.aspectRatio, imageSize: imageParams.imageSize } }
94
+ : {}),
95
+ }
90
96
  : {
91
97
  response_modalities: ["TEXT", "IMAGE"],
92
- response_format: {
93
- image: {
94
- aspect_ratio: imageParams.aspectRatio,
95
- image_size: imageParams.imageSize,
96
- },
97
- },
98
+ ...(imageParams
99
+ ? { response_format: { image: { aspect_ratio: imageParams.aspectRatio, image_size: imageParams.imageSize } } }
100
+ : {}),
98
101
  };
99
102
  const configKey = useVertex ? "generationConfig" : "generation_config";
100
103
  const body = { contents: buildContents(prompt, references), [configKey]: generationConfig };
@@ -1,4 +1,4 @@
1
- import { classifyUpstreamError, classifyUpstreamErrorCode } from "./errorClassify.js";
1
+ import { classifyUpstreamError, classifyUpstreamErrorCode, classifyModerationStage } from "./errorClassify.js";
2
2
  import { safeDiagnosticLabel } from "./responsesParse.js";
3
3
  import { RESPONSE_DIAGNOSTIC_CODES } from "./responsesErrors.js";
4
4
  const PASSTHROUGH_CODES = new Set([
@@ -192,9 +192,11 @@ export function normalizeGenerationFailure(lastErr, options = {}) {
192
192
  return err;
193
193
  }
194
194
  if (SAFETY_CODES.has(code)) {
195
+ const stage = classifyModerationStage(lastErr?.message);
195
196
  const err = new Error(options.safetyMessage || lastErr?.message || "Content generation refused after retries");
196
197
  err.code = "SAFETY_REFUSAL";
197
198
  err.status = 422;
199
+ err.moderationStage = stage;
198
200
  err.cause = lastErr;
199
201
  return err;
200
202
  }
@@ -0,0 +1,25 @@
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { atomicWriteJson } from "./atomicWrite.js";
4
+ const MAX_ENTRIES = 200;
5
+ let writeQueue = Promise.resolve();
6
+ async function readEntries(path) {
7
+ try {
8
+ const parsed = JSON.parse(await readFile(path, "utf8"));
9
+ return Array.isArray(parsed) ? parsed.slice(0, MAX_ENTRIES) : [];
10
+ }
11
+ catch {
12
+ return [];
13
+ }
14
+ }
15
+ export async function listGenerationRequestLog(path) {
16
+ return readEntries(path);
17
+ }
18
+ export function appendGenerationRequestLog(path, entry) {
19
+ writeQueue = writeQueue.catch(() => undefined).then(async () => {
20
+ await mkdir(dirname(path), { recursive: true });
21
+ const entries = await readEntries(path);
22
+ await atomicWriteJson(path, [entry, ...entries].slice(0, MAX_ENTRIES));
23
+ });
24
+ return writeQueue;
25
+ }