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
@@ -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" ||
@@ -0,0 +1,281 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
3
+ import { atomicWriteJson } from "./atomicWrite.js";
4
+ import { join } from "node:path";
5
+ import { ulid } from "ulid";
6
+ import { embedImageMetadataBestEffort } from "./imageMetadataStore.js";
7
+ import { invalidateHistoryIndex } from "./historyIndex.js";
8
+ import { logEvent } from "./logger.js";
9
+ import { detectImageMimeFromB64 } from "./refs.js";
10
+ import { resolveProviderOptions } from "./providerOptions.js";
11
+ import { generateViaResponses } from "./responsesImageAdapter.js";
12
+ import { generateViaGrok } from "./grokImageAdapter.js";
13
+ import { generateViaAgy } from "./agyImageAdapter.js";
14
+ import { generateVideoViaGrok } from "./grokVideoAdapter.js";
15
+ import { parseVideoParams } from "./agentGenerationPlanner.js";
16
+ import { appendAgentTurn, getAgentImages, getAgentSession, importAgentImage, } from "./agentStore.js";
17
+ import { errInfo } from "./errInfo.js";
18
+ import { forceImagePrompt, isTextOnlyResult, textOnlyError, notFound } from "./agentRuntime.js";
19
+ const AGENT_GROK_PLANNER_MODEL = "grok-4.3";
20
+ export async function generateAgentImageWithRetry(ctx, sessionId, prompt, manifest, webSearchEnabled, options) {
21
+ let lastError = null;
22
+ for (let attempt = 0; attempt < 2; attempt++) {
23
+ try {
24
+ const forcedPrompt = attempt === 0 ? prompt : forceImagePrompt(prompt);
25
+ const result = await generateAgentImage(ctx, sessionId, forcedPrompt, manifest, webSearchEnabled, options);
26
+ if (result.image)
27
+ return result;
28
+ }
29
+ catch (error) {
30
+ lastError = error;
31
+ if (!isTextOnlyResult(error))
32
+ throw error;
33
+ if (attempt === 1)
34
+ break;
35
+ appendAgentTurn({
36
+ sessionId,
37
+ role: "tool",
38
+ text: "ima2.generate_image retry: text-only result rejected",
39
+ status: "error",
40
+ });
41
+ }
42
+ }
43
+ throw textOnlyError(lastError);
44
+ }
45
+ async function generateAgentImage(ctx, sessionId, prompt, manifest, webSearchEnabled, options) {
46
+ const requestId = options.requestId ?? `agent_${ulid()}`;
47
+ const grokPlannerModel = isAgentGrokPlannerModel(options.model) ? options.model : undefined;
48
+ const providerOptions = resolveProviderOptions(ctx, {
49
+ provider: options.provider ?? "oauth",
50
+ rawModel: grokPlannerModel ? undefined : options.model,
51
+ rawReasoningEffort: options.reasoningEffort,
52
+ rawSize: options.size ?? "1024x1024",
53
+ rawWebSearchEnabled: webSearchEnabled,
54
+ searchMode: webSearchEnabled ? "on" : "off",
55
+ });
56
+ if (providerOptions.error) {
57
+ const err = new Error(providerOptions.error);
58
+ err.code = providerOptions.code;
59
+ err.status = providerOptions.status;
60
+ throw err;
61
+ }
62
+ const activeProvider = providerOptions.provider;
63
+ const effectiveModel = activeProvider === "grok" && options.quality === "high"
64
+ ? "grok-imagine-image-quality"
65
+ : providerOptions.model;
66
+ const response = activeProvider === "agy"
67
+ ? await generateViaAgy(`${manifest}\n\nUser request:\n${prompt}`, {
68
+ requestId,
69
+ signal: options.signal ?? undefined,
70
+ })
71
+ : activeProvider === "grok"
72
+ ? await generateViaGrok(`${manifest}\n\nUser request:\n${prompt}`, ctx, {
73
+ model: effectiveModel,
74
+ size: providerOptions.size,
75
+ requestId,
76
+ signal: options.signal ?? undefined,
77
+ references: await loadAgentCurrentImageReferences(ctx, sessionId, options.sourceImagePolicy ?? "none"),
78
+ plannerModel: grokPlannerModel,
79
+ })
80
+ : await generateViaResponses(activeProvider, `${manifest}\n\nUser request:\n${prompt}`, options.quality ?? "medium", providerOptions.size, options.moderation ?? "low", [], requestId, "auto", ctx, {
81
+ model: providerOptions.model,
82
+ reasoningEffort: providerOptions.reasoningEffort,
83
+ webSearchEnabled,
84
+ signal: options.signal,
85
+ });
86
+ const format = activeProvider === "grok" || activeProvider === "agy"
87
+ ? imageFormatFromMime(("mime" in response ? response.mime : undefined) || detectImageMimeFromB64(response.b64) || "image/jpeg")
88
+ : options.format ?? "png";
89
+ const image = await persistAgentImage(ctx, sessionId, prompt, format, requestId, response, {
90
+ provider: String(activeProvider),
91
+ model: String(effectiveModel),
92
+ });
93
+ const responseText = "text" in response && typeof response.text === "string" ? response.text : null;
94
+ return { image, webSearchCalls: response.webSearchCalls || 0, text: responseText, provider: activeProvider };
95
+ }
96
+ async function loadAgentCurrentImageReferences(ctx, sessionId, policy) {
97
+ if (policy === "none") {
98
+ logEvent("agent", "grok_ref_policy", { sessionId, policy, attached: false });
99
+ return [];
100
+ }
101
+ const session = getAgentSession(sessionId);
102
+ const currentImage = session?.lastImageId
103
+ ? getAgentImages(sessionId).find((image) => image.id === session.lastImageId)
104
+ : null;
105
+ if (!currentImage?.filename) {
106
+ logEvent("agent", "grok_ref_policy", { sessionId, policy, attached: false });
107
+ return [];
108
+ }
109
+ try {
110
+ const b64 = (await readFile(join(ctx.config.storage.generatedDir, currentImage.filename))).toString("base64");
111
+ const mime = detectImageMimeFromB64(b64);
112
+ logEvent("agent", "grok_ref_policy", { sessionId, policy, attached: true, filename: currentImage.filename });
113
+ return [{ b64, declaredMime: mime, detectedMime: mime }];
114
+ }
115
+ catch (error) {
116
+ const err = errInfo(error);
117
+ logEvent("agent", "grok_ref_missing", { sessionId, filename: currentImage.filename, code: err.code, message: err.message });
118
+ return [];
119
+ }
120
+ }
121
+ function imageFormatFromMime(mime) {
122
+ if (mime === "image/jpeg")
123
+ return "jpeg";
124
+ if (mime === "image/webp")
125
+ return "webp";
126
+ return "png";
127
+ }
128
+ async function persistAgentImage(ctx, sessionId, prompt, format, requestId, response, generation) {
129
+ await mkdir(ctx.config.storage.generatedDir, { recursive: true });
130
+ const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
131
+ const filename = `${Date.now()}_${rand}_agent.${format}`;
132
+ const meta = {
133
+ kind: "agent",
134
+ requestId,
135
+ sessionId,
136
+ prompt,
137
+ userPrompt: prompt,
138
+ revisedPrompt: response.revisedPrompt ?? null,
139
+ provider: generation.provider,
140
+ model: generation.model,
141
+ createdAt: Date.now(),
142
+ usage: response.usage ?? null,
143
+ webSearchCalls: response.webSearchCalls ?? 0,
144
+ };
145
+ const embedded = await embedImageMetadataBestEffort(Buffer.from(response.b64, "base64"), format, meta, {
146
+ version: ctx.packageVersion,
147
+ });
148
+ const filePath = join(ctx.config.storage.generatedDir, filename);
149
+ await writeFile(filePath, embedded.buffer);
150
+ try {
151
+ await atomicWriteJson(`${filePath}.json`, meta);
152
+ }
153
+ catch (err) {
154
+ await unlink(filePath).catch(() => { });
155
+ throw err;
156
+ }
157
+ invalidateHistoryIndex();
158
+ logEvent("agent", "saved", { requestId, sessionId, filename });
159
+ return importAgentImage(sessionId, {
160
+ id: `ai_${ulid()}`,
161
+ filename,
162
+ url: `/generated/${filename}`,
163
+ prompt,
164
+ revisedPrompt: response.revisedPrompt ?? null,
165
+ createdAt: Date.now(),
166
+ });
167
+ }
168
+ export async function runAgentVideoGeneration(ctx, sessionId, prompt, options = {}) {
169
+ const session = getAgentSession(sessionId);
170
+ if (!session)
171
+ throw notFound(sessionId);
172
+ if (!options.skipUserTurn) {
173
+ appendAgentTurn({ sessionId, role: "user", text: prompt, status: "complete" });
174
+ }
175
+ const requestId = options.requestId ?? `agent_video_${ulid()}`;
176
+ const startedAt = Date.now();
177
+ // Auto I2V: if session has a last image, use it as source
178
+ let sourceImage;
179
+ let mode = "text-to-video";
180
+ if (session.lastImageId) {
181
+ const images = getAgentImages(sessionId);
182
+ const lastImage = images.find((img) => img.id === session.lastImageId);
183
+ if (lastImage?.filename && !lastImage.filename.endsWith(".mp4")) {
184
+ try {
185
+ const { loadAssetB64 } = await import("./nodeStore.js");
186
+ sourceImage = await loadAssetB64(ctx.rootDir, lastImage.filename, ctx.config.storage.generatedDir);
187
+ mode = "image-to-video";
188
+ }
189
+ catch { /* fallback to T2V */ }
190
+ }
191
+ }
192
+ // LLM-planned params win; the prompt regex remains the fallback extractor.
193
+ const parsedParams = parseVideoParams(prompt);
194
+ const videoParams = {
195
+ duration: options.videoParams?.duration ?? parsedParams.duration,
196
+ resolution: options.videoParams?.resolution ?? parsedParams.resolution,
197
+ aspectRatio: options.videoParams?.aspectRatio ?? parsedParams.aspectRatio,
198
+ };
199
+ const result = await generateVideoViaGrok(prompt, ctx, {
200
+ model: "grok-imagine-video",
201
+ mode,
202
+ sourceImage,
203
+ duration: videoParams.duration ?? 5,
204
+ resolution: videoParams.resolution ?? "480p",
205
+ aspectRatio: (videoParams.aspectRatio ?? "auto"),
206
+ requestId,
207
+ signal: options.signal ?? undefined,
208
+ plannerModel: isAgentGrokPlannerModel(options.model) ? options.model : undefined,
209
+ });
210
+ const video = await persistAgentVideo(ctx, sessionId, prompt, requestId, result);
211
+ const finishedAt = Date.now();
212
+ const toolCall = {
213
+ id: `tc_video_${ulid()}`,
214
+ name: "ima2.generate_video",
215
+ status: "complete",
216
+ startedAt,
217
+ finishedAt,
218
+ durationMs: finishedAt - startedAt,
219
+ requestId,
220
+ inputSummary: prompt,
221
+ outputSummary: `Generated video ${video.filename}.`,
222
+ imageIds: [video.id],
223
+ };
224
+ appendAgentTurn({
225
+ sessionId,
226
+ role: "tool",
227
+ text: "ima2.generate_video",
228
+ imageIds: [video.id],
229
+ status: "complete",
230
+ raw: { toolCalls: [toolCall] },
231
+ });
232
+ const assistantTurn = appendAgentTurn({
233
+ sessionId,
234
+ role: "assistant",
235
+ text: options.assistantText?.trim() || `Generated 1 video artifact. ${result.revisedPrompt}`,
236
+ imageIds: [video.id],
237
+ status: "complete",
238
+ });
239
+ return { assistantTurn, imageIds: [video.id], webFindingIds: [] };
240
+ }
241
+ function isAgentGrokPlannerModel(model) {
242
+ return model === AGENT_GROK_PLANNER_MODEL;
243
+ }
244
+ async function persistAgentVideo(ctx, sessionId, prompt, requestId, result) {
245
+ await mkdir(ctx.config.storage.generatedDir, { recursive: true });
246
+ const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
247
+ const filename = `${Date.now()}_${rand}_agent.mp4`;
248
+ const meta = {
249
+ kind: "agent",
250
+ mediaType: "video",
251
+ requestId,
252
+ sessionId,
253
+ prompt,
254
+ userPrompt: prompt,
255
+ revisedPrompt: result.revisedPrompt,
256
+ provider: "grok",
257
+ model: "grok-imagine-video",
258
+ createdAt: Date.now(),
259
+ usage: result.usage,
260
+ webSearchCalls: result.webSearchCalls,
261
+ };
262
+ const filePath = join(ctx.config.storage.generatedDir, filename);
263
+ await writeFile(filePath, result.videoBuffer);
264
+ try {
265
+ await atomicWriteJson(`${filePath}.json`, meta);
266
+ }
267
+ catch (err) {
268
+ await unlink(filePath).catch(() => { });
269
+ throw err;
270
+ }
271
+ invalidateHistoryIndex();
272
+ logEvent("agent", "video_saved", { requestId, sessionId, filename });
273
+ return importAgentImage(sessionId, {
274
+ id: `ai_${ulid()}`,
275
+ filename,
276
+ url: `/generated/${filename}`,
277
+ prompt,
278
+ revisedPrompt: result.revisedPrompt,
279
+ createdAt: Date.now(),
280
+ });
281
+ }
@@ -0,0 +1,172 @@
1
+ import { normalizeAgentGenerationPlan } from "./agentGenerationPlanner.js";
2
+ import { readResponsesTextPayload } from "./agentQuestionResponder.js";
3
+ import { formatToolManifestForPrompt } from "./agentToolManifest.js";
4
+ import { getAgentSession } from "./agentStore.js";
5
+ import { errInfo } from "./errInfo.js";
6
+ import { logEvent } from "./logger.js";
7
+ import { getGrokEndpoint, getPlannerConfig } from "./grokImageCore.js";
8
+ import { waitForOAuthReady } from "./oauthProxy/runtime.js";
9
+ import { requireRuntimeContext } from "./runtimeContext.js";
10
+ function buildPlannerDeveloperPrompt(hasSourceImage, imageCount) {
11
+ return [
12
+ "You are the generation planner for the ima2 Agent. Decide how to fulfill the user's request using the available tools.",
13
+ "",
14
+ "Available tools (name, purpose, parameter schema):",
15
+ formatToolManifestForPrompt(),
16
+ "",
17
+ "Tool execution contract:",
18
+ "- You do not call provider image/video APIs directly. You choose a plan; the ima2 runtime executes the corresponding ima2.* tools.",
19
+ "- The session model is the planner/LLM model, not an image or video model. For example, grok-4.3 means Grok planner/provider routing; image generation still uses ima2.generate_image with the configured Grok image backend.",
20
+ "- For image creation/edit requests choose mode single or fanout, which maps to ima2.get_image_context followed by ima2.generate_image.",
21
+ "- For image creation/edit requests, also choose sourceImagePolicy: none for a fresh image, current to use the session's current image as an edit/reference input, or auto only when genuinely ambiguous.",
22
+ "- For video creation requests choose mode video, which maps to ima2.generate_video. Never put video model names in prompts.",
23
+ "- For failure questions choose mode errors, which maps to ima2.get_generation_errors.",
24
+ "",
25
+ "Session context:",
26
+ `- Images in session: ${imageCount}`,
27
+ `- Last image available as image-to-video source: ${hasSourceImage ? "yes" : "no"}`,
28
+ "",
29
+ "Decide ONE plan and respond with ONLY a JSON object (no prose, no code fences):",
30
+ '{"mode":"single|fanout|video|question|errors","prompts":["..."],"plannedVariants":1,"plannedParallelism":1,"sourceImagePolicy":"none|current|auto","videoParams":{"duration":5,"resolution":"480p","aspectRatio":"auto"},"assistantText":"...","reason":"short reason"}',
31
+ "",
32
+ "Rules:",
33
+ "- You are a conversational assistant first. Generate media ONLY when the user clearly asks you to create or edit an image/video. Everything else (questions, chat, greetings, feedback, follow-ups) is mode question.",
34
+ "- mode single: one image. prompts has exactly 1 entry (the generation prompt, user language preserved).",
35
+ "- mode fanout: multiple image variants. prompts has one entry per variant; respect any count the user asked for.",
36
+ "- sourceImagePolicy for single/fanout: use none for new/fresh/separate/from-scratch requests, including '새로', '별도', 'i2i 말고', '새로운 방식', 'new image', 'from scratch', 'without reference'.",
37
+ "- sourceImagePolicy for single/fanout: use current only when the user explicitly asks to use/edit/modify/transform/reference the current image, including '이 이미지', '현재 이미지', '방금 그거', '참조', 'reference', 'i2i', 'image-to-image', '유지해서'.",
38
+ "- sourceImagePolicy for plain image requests with no explicit reference wording is none.",
39
+ "- mode video: one video via ima2.generate_video. Choose it only when the user asks to CREATE a video. prompts has exactly 1 entry. Extract duration (1-15 s), resolution (480p|720p), aspectRatio (auto|1:1|16:9|9:16|4:3|3:4|3:2|2:3) from the request into videoParams; omit fields the user did not specify.",
40
+ "- mode question: the user is NOT requesting generation — a question (capabilities, how-to, status), small talk, a greeting, or feedback — e.g. '영상 생성가능하니?', 'can you make videos?', '고마워'. prompts must be []. Write the full answer in assistantText. Mentioning a media word like 'video' or '영상' inside a question does NOT make it a generation request.",
41
+ "- mode errors: the user is asking why a previous generation failed or about recent errors. prompts must be [].",
42
+ "- assistantText: REQUIRED for every mode, written in the user's language. For question/errors it is the full reply. For single/fanout/video it is a short natural chat reply telling the user what you are creating (1-2 sentences, no markdown headings).",
43
+ "- Preserve the user's prompt content; do not censor, embellish, or translate it.",
44
+ "- reason: one short sentence explaining the decision.",
45
+ ].join("\n");
46
+ }
47
+ export async function requestAgentPlanFromModel(ctxRaw, input) {
48
+ const ctx = requireRuntimeContext(ctxRaw);
49
+ const plannerCfg = ctx.config.agentPlanner;
50
+ if (!plannerCfg?.enabled)
51
+ return null;
52
+ if (input.settings.provider === "agy")
53
+ return null;
54
+ const timeoutMs = plannerCfg.timeoutMs ?? 30_000;
55
+ const controller = new AbortController();
56
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
57
+ const signal = input.signal ? AbortSignal.any([controller.signal, input.signal]) : controller.signal;
58
+ try {
59
+ const session = getAgentSession(input.sessionId);
60
+ const developerPrompt = buildPlannerDeveloperPrompt(Boolean(session?.lastImageId), session?.imageCount ?? 0);
61
+ const rawText = input.settings.provider === "grok"
62
+ ? await requestGrokPlan(ctx, developerPrompt, input.prompt, signal)
63
+ : await requestResponsesPlan(ctx, developerPrompt, input.prompt, input.settings, signal);
64
+ const parsed = extractJsonObject(rawText);
65
+ if (!parsed) {
66
+ logEvent("agent_planner", "parse_failed", { requestId: input.requestId, provider: input.settings.provider, chars: rawText.length });
67
+ return null;
68
+ }
69
+ const plan = normalizeAgentGenerationPlan(input.prompt, { ...parsed, source: "llm-planner" }, input.settings);
70
+ logEvent("agent_planner", "planned", {
71
+ requestId: input.requestId,
72
+ provider: input.settings.provider,
73
+ mode: plan.mode,
74
+ plannedVariants: plan.plannedVariants,
75
+ source: plan.source,
76
+ });
77
+ return plan;
78
+ }
79
+ catch (error) {
80
+ const err = errInfo(error);
81
+ logEvent("agent_planner", "fallback", {
82
+ requestId: input.requestId,
83
+ provider: input.settings.provider,
84
+ code: err.name === "AbortError" ? "AGENT_PLANNER_TIMEOUT" : err.code,
85
+ message: err.message,
86
+ });
87
+ return null;
88
+ }
89
+ finally {
90
+ clearTimeout(timer);
91
+ }
92
+ }
93
+ async function requestGrokPlan(ctx, developerPrompt, userPrompt, signal) {
94
+ const { url, headers } = getGrokEndpoint(ctx, "/v1/chat/completions");
95
+ const planner = getPlannerConfig(ctx);
96
+ const res = await fetch(url, {
97
+ method: "POST",
98
+ headers,
99
+ signal,
100
+ body: JSON.stringify({
101
+ model: planner.model,
102
+ stream: false,
103
+ messages: [
104
+ { role: "system", content: developerPrompt },
105
+ { role: "user", content: userPrompt },
106
+ ],
107
+ }),
108
+ });
109
+ if (!res.ok)
110
+ throw plannerHttpError("grok", res.status);
111
+ const body = await res.json();
112
+ return typeof body.choices?.[0]?.message?.content === "string" ? body.choices[0].message.content : "";
113
+ }
114
+ async function requestResponsesPlan(ctx, developerPrompt, userPrompt, settings, signal) {
115
+ let url;
116
+ let headers;
117
+ if (settings.provider === "api") {
118
+ if (!ctx.apiKey)
119
+ throw plannerError("API key is required for Agent planner", "API_KEY_REQUIRED", 401);
120
+ url = "https://api.openai.com/v1/responses";
121
+ headers = { "Content-Type": "application/json", Accept: "text/event-stream", Authorization: `Bearer ${ctx.apiKey}` };
122
+ }
123
+ else {
124
+ await waitForOAuthReady(ctx);
125
+ url = `${ctx.oauthUrl}/v1/responses`;
126
+ headers = { "Content-Type": "application/json", Accept: "text/event-stream" };
127
+ }
128
+ // stream:true is required — the bundled OAuth proxy returns an empty
129
+ // `output` array for non-streaming Responses calls, which used to make the
130
+ // planner silently fall back to the regex-derived image plan.
131
+ const res = await fetch(url, {
132
+ method: "POST",
133
+ headers,
134
+ signal,
135
+ body: JSON.stringify({
136
+ model: settings.model,
137
+ input: [
138
+ { role: "developer", content: developerPrompt },
139
+ { role: "user", content: userPrompt },
140
+ ],
141
+ reasoning: { effort: "low" },
142
+ stream: true,
143
+ }),
144
+ });
145
+ if (!res.ok)
146
+ throw plannerHttpError(settings.provider, res.status);
147
+ const payload = await readResponsesTextPayload(res);
148
+ return payload.text;
149
+ }
150
+ export function extractJsonObject(raw) {
151
+ const text = raw.replace(/```(?:json)?/gi, "").trim();
152
+ const start = text.indexOf("{");
153
+ const end = text.lastIndexOf("}");
154
+ if (start === -1 || end === -1 || end <= start)
155
+ return null;
156
+ try {
157
+ const parsed = JSON.parse(text.slice(start, end + 1));
158
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }
164
+ function plannerHttpError(provider, status) {
165
+ return plannerError(`Agent planner upstream rejected the request (${provider})`, "AGENT_PLANNER_UPSTREAM_FAILED", status >= 400 && status < 600 ? status : 502);
166
+ }
167
+ function plannerError(message, code, status) {
168
+ const err = new Error(message);
169
+ err.code = code;
170
+ err.status = status;
171
+ return err;
172
+ }
@@ -40,9 +40,7 @@ export async function requestAgentQuestionAnswer(ctxRaw, question, options = {})
40
40
  });
41
41
  if (!res.ok)
42
42
  throw await questionHttpError(res, options.provider);
43
- const parsed = res.headers.get("content-type")?.includes("text/event-stream")
44
- ? await readTextStream(res)
45
- : readTextBody(await res.json());
43
+ const parsed = await readResponsesTextPayload(res);
46
44
  if (!parsed.text.trim())
47
45
  throw questionError("Agent question returned an empty response", "AGENT_QUESTION_EMPTY", 502);
48
46
  return { text: parsed.text.trim().slice(0, 4_000), usage: parsed.usage };
@@ -94,6 +92,14 @@ async function questionHttpError(res, provider) {
94
92
  throw errInfo(error).raw;
95
93
  }
96
94
  }
95
+ // Shared with the agent planner: the bundled OAuth proxy returns an empty
96
+ // `output` array for stream:false Responses calls, so any text consumer must
97
+ // be able to read the SSE stream instead of relying on the JSON body.
98
+ export async function readResponsesTextPayload(res) {
99
+ return res.headers.get("content-type")?.includes("text/event-stream")
100
+ ? readTextStream(res)
101
+ : readTextBody(await res.json());
102
+ }
97
103
  async function readTextStream(res) {
98
104
  try {
99
105
  const reader = res.body?.getReader();