onbuzz 4.9.13 → 4.10.0

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 (451) hide show
  1. package/node_modules/glob/README.md +31 -5
  2. package/node_modules/glob/dist/commonjs/glob.d.ts +8 -0
  3. package/node_modules/glob/dist/commonjs/glob.d.ts.map +1 -1
  4. package/node_modules/glob/dist/commonjs/glob.js +2 -1
  5. package/node_modules/glob/dist/commonjs/glob.js.map +1 -1
  6. package/node_modules/glob/dist/commonjs/index.min.js +3 -3
  7. package/node_modules/glob/dist/commonjs/index.min.js.map +4 -4
  8. package/node_modules/glob/dist/commonjs/pattern.d.ts +3 -0
  9. package/node_modules/glob/dist/commonjs/pattern.d.ts.map +1 -1
  10. package/node_modules/glob/dist/commonjs/pattern.js +4 -0
  11. package/node_modules/glob/dist/commonjs/pattern.js.map +1 -1
  12. package/node_modules/glob/dist/esm/glob.d.ts +8 -0
  13. package/node_modules/glob/dist/esm/glob.d.ts.map +1 -1
  14. package/node_modules/glob/dist/esm/glob.js +2 -1
  15. package/node_modules/glob/dist/esm/glob.js.map +1 -1
  16. package/node_modules/glob/dist/esm/index.min.js +3 -3
  17. package/node_modules/glob/dist/esm/index.min.js.map +4 -4
  18. package/node_modules/glob/dist/esm/pattern.d.ts +3 -0
  19. package/node_modules/glob/dist/esm/pattern.d.ts.map +1 -1
  20. package/node_modules/glob/dist/esm/pattern.js +4 -0
  21. package/node_modules/glob/dist/esm/pattern.js.map +1 -1
  22. package/node_modules/{@isaacs → glob/node_modules}/balanced-match/README.md +7 -10
  23. package/node_modules/{@isaacs → glob/node_modules}/balanced-match/package.json +7 -18
  24. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/README.md +3 -6
  25. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.js +6 -4
  26. package/node_modules/glob/node_modules/brace-expansion/dist/commonjs/index.js.map +1 -0
  27. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.js +6 -4
  28. package/node_modules/glob/node_modules/brace-expansion/dist/esm/index.js.map +1 -0
  29. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/package.json +11 -7
  30. package/node_modules/glob/node_modules/minimatch/README.md +76 -1
  31. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts +1 -1
  32. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts.map +1 -1
  33. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.js.map +1 -1
  34. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts +4 -2
  35. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts.map +1 -1
  36. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js +309 -55
  37. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js.map +1 -1
  38. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.d.ts.map +1 -1
  39. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js +2 -4
  40. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js.map +1 -1
  41. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts +1 -1
  42. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts.map +1 -1
  43. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js +4 -4
  44. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js.map +1 -1
  45. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts +81 -1
  46. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts.map +1 -1
  47. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js +232 -134
  48. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js.map +1 -1
  49. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts +1 -1
  50. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts.map +1 -1
  51. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js +8 -8
  52. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js.map +1 -1
  53. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts +1 -1
  54. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts.map +1 -1
  55. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -1
  56. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts +4 -2
  57. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts.map +1 -1
  58. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js +309 -55
  59. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js.map +1 -1
  60. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.d.ts.map +1 -1
  61. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js +2 -4
  62. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -1
  63. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts +1 -1
  64. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts.map +1 -1
  65. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js +4 -4
  66. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js.map +1 -1
  67. package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts +81 -1
  68. package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts.map +1 -1
  69. package/node_modules/glob/node_modules/minimatch/dist/esm/index.js +232 -134
  70. package/node_modules/glob/node_modules/minimatch/dist/esm/index.js.map +1 -1
  71. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts +1 -1
  72. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts.map +1 -1
  73. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js +8 -8
  74. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js.map +1 -1
  75. package/node_modules/glob/node_modules/minimatch/package.json +17 -11
  76. package/node_modules/glob/package.json +10 -13
  77. package/node_modules/minipass/LICENSE.md +55 -0
  78. package/node_modules/minipass/dist/commonjs/index.d.ts +12 -16
  79. package/node_modules/minipass/dist/commonjs/index.d.ts.map +1 -1
  80. package/node_modules/minipass/dist/commonjs/index.js +13 -3
  81. package/node_modules/minipass/dist/commonjs/index.js.map +1 -1
  82. package/node_modules/minipass/dist/esm/index.d.ts +12 -16
  83. package/node_modules/minipass/dist/esm/index.d.ts.map +1 -1
  84. package/node_modules/minipass/dist/esm/index.js +3 -1
  85. package/node_modules/minipass/dist/esm/index.js.map +1 -1
  86. package/node_modules/minipass/package.json +9 -14
  87. package/node_modules/path-scurry/node_modules/lru-cache/README.md +96 -10
  88. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.d.ts.map +1 -0
  89. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.js.map +1 -0
  90. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.d.ts +5 -0
  91. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.js +7 -0
  92. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts +1400 -0
  93. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts.map +1 -0
  94. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js +1726 -0
  95. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js.map +1 -0
  96. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js +2 -0
  97. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js.map +7 -0
  98. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts +12 -0
  99. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts.map +1 -0
  100. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js +10 -0
  101. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js.map +1 -0
  102. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.cjs.map +1 -0
  103. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.d.cts.map +1 -0
  104. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.d.ts +5 -0
  105. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.js +7 -0
  106. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts +109 -32
  107. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts.map +1 -1
  108. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js +334 -197
  109. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js.map +1 -1
  110. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js +1 -1
  111. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js.map +4 -4
  112. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.d.ts.map +1 -0
  113. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.js.map +1 -0
  114. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.d.ts +5 -0
  115. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.js +9 -0
  116. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts +1400 -0
  117. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts.map +1 -0
  118. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js +1726 -0
  119. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js.map +1 -0
  120. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js +2 -0
  121. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js.map +7 -0
  122. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts +12 -0
  123. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts.map +1 -0
  124. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js +10 -0
  125. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js.map +1 -0
  126. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts +12 -0
  127. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts.map +1 -0
  128. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js +10 -0
  129. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js.map +1 -0
  130. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.d.ts.map +1 -0
  131. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.js.map +1 -0
  132. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.d.ts +5 -0
  133. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.js +4 -0
  134. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts +1400 -0
  135. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts.map +1 -0
  136. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js +1722 -0
  137. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js.map +1 -0
  138. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js +2 -0
  139. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js.map +7 -0
  140. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts +12 -0
  141. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts.map +1 -0
  142. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js +7 -0
  143. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js.map +1 -0
  144. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.d.mts.map +1 -0
  145. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.mjs.map +1 -0
  146. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.d.ts +5 -0
  147. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.js +19 -0
  148. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts +109 -32
  149. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts.map +1 -1
  150. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js +333 -196
  151. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js.map +1 -1
  152. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js +1 -1
  153. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js.map +4 -4
  154. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.d.ts.map +1 -0
  155. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.js.map +1 -0
  156. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.d.ts +5 -0
  157. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.js +6 -0
  158. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts +1400 -0
  159. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts.map +1 -0
  160. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js +1722 -0
  161. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js.map +1 -0
  162. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js +2 -0
  163. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js.map +7 -0
  164. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts +12 -0
  165. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts.map +1 -0
  166. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js +7 -0
  167. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js.map +1 -0
  168. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts +12 -0
  169. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts.map +1 -0
  170. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js +7 -0
  171. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js.map +1 -0
  172. package/node_modules/path-scurry/node_modules/lru-cache/package.json +71 -18
  173. package/node_modules/path-scurry/package.json +8 -24
  174. package/package.json +1 -1
  175. package/scripts/debug-balance-probe.mjs +35 -35
  176. package/scripts/push-image.sh +43 -43
  177. package/scripts/setup-acr.sh +65 -65
  178. package/scripts/verify-optional-deps.js +96 -1
  179. package/src/__tests__/composioCliFlags.test.js +239 -239
  180. package/src/analyzers/CSSAnalyzer.js +298 -297
  181. package/src/analyzers/ConfigValidator.js +691 -690
  182. package/src/analyzers/ESLintAnalyzer.js +320 -320
  183. package/src/analyzers/JavaScriptAnalyzer.js +260 -261
  184. package/src/analyzers/PrettierFormatter.js +246 -247
  185. package/src/analyzers/PythonAnalyzer.js +283 -283
  186. package/src/analyzers/SecurityAnalyzer.js +729 -729
  187. package/src/analyzers/SparrowAnalyzer.js +341 -341
  188. package/src/analyzers/TypeScriptAnalyzer.js +247 -247
  189. package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -41
  190. package/src/analyzers/__tests__/ConfigValidator.test.js +362 -362
  191. package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -40
  192. package/src/analyzers/__tests__/PythonAnalyzer.test.js +205 -208
  193. package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -303
  194. package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -187
  195. package/src/analyzers/codeCloneDetector/analyzer.js +344 -344
  196. package/src/analyzers/codeCloneDetector/detector.js +250 -250
  197. package/src/analyzers/codeCloneDetector/index.js +194 -192
  198. package/src/analyzers/codeCloneDetector/parser.js +199 -199
  199. package/src/core/__tests__/agentPool.test.js +866 -866
  200. package/src/core/__tests__/agentPoolAutoResume.test.js +209 -209
  201. package/src/core/__tests__/agentPoolWakeOnMessage.test.js +315 -315
  202. package/src/core/__tests__/agentScheduler.emptyResponseChatStall.test.js +213 -213
  203. package/src/core/__tests__/agentScheduler.errorCategorisation.test.js +246 -246
  204. package/src/core/__tests__/agentScheduler.firstChunkTimeout.test.js +138 -138
  205. package/src/core/__tests__/agentScheduler.modeTransitions.test.js +233 -233
  206. package/src/core/__tests__/agentScheduler.nativePromptPick.test.js +319 -319
  207. package/src/core/__tests__/agentScheduler.taskLifecycleInstruction.test.js +78 -78
  208. package/src/core/__tests__/agentScheduler.visualizer.test.js +258 -258
  209. package/src/core/__tests__/flowCheckpointStore.test.js +140 -140
  210. package/src/core/__tests__/flowEndToEnd.test.js +565 -565
  211. package/src/core/__tests__/flowFieldMapping.test.js +188 -189
  212. package/src/core/__tests__/flowLintClientMirror.test.js +96 -98
  213. package/src/core/__tests__/flowSavePayload.test.js +170 -169
  214. package/src/core/__tests__/flowTemplates.test.js +311 -311
  215. package/src/core/__tests__/flowVersionStore.test.js +123 -123
  216. package/src/core/__tests__/messageProcessor.test.js +669 -669
  217. package/src/core/__tests__/stateManager.test.js +0 -1
  218. package/src/core/agentPool.js +2474 -2475
  219. package/src/core/agentScheduler.js +1 -4
  220. package/src/core/contextManager.js +708 -708
  221. package/src/core/flowExecutor.js +1510 -1510
  222. package/src/core/flowFieldMapping.js +136 -138
  223. package/src/core/messageProcessor.js +953 -954
  224. package/src/core/orchestrator.js +593 -595
  225. package/src/core/stateManager.js +1765 -1752
  226. package/src/index.js +1221 -1221
  227. package/src/interfaces/__tests__/archivedAgentDelete.test.js +207 -207
  228. package/src/interfaces/__tests__/bulkAgentRoute.test.js +361 -361
  229. package/src/interfaces/__tests__/imageServing.test.js +228 -228
  230. package/src/interfaces/__tests__/remoteSessionAuth.test.js +308 -308
  231. package/src/interfaces/__tests__/videoJobsRoutes.test.js +178 -179
  232. package/src/interfaces/__tests__/webServer.marketplace.test.js +629 -629
  233. package/src/interfaces/schedulerRoutes.js +50 -50
  234. package/src/interfaces/terminal/__tests__/smoke/connection.test.js +341 -350
  235. package/src/interfaces/terminal/__tests__/smoke/enhancements.test.js +156 -156
  236. package/src/interfaces/terminal/__tests__/smoke/imports.test.js +325 -330
  237. package/src/interfaces/terminal/__tests__/smoke/tools.test.js +385 -388
  238. package/src/interfaces/terminal/api/session.js +265 -266
  239. package/src/interfaces/terminal/api/websocket.js +496 -497
  240. package/src/interfaces/terminal/components/AgentCreator.js +691 -705
  241. package/src/interfaces/terminal/components/AgentEditor.js +676 -678
  242. package/src/interfaces/terminal/components/AgentSwitcher.js +331 -330
  243. package/src/interfaces/terminal/components/ErrorPanel.js +263 -264
  244. package/src/interfaces/terminal/components/Header.js +28 -28
  245. package/src/interfaces/terminal/components/Layout.js +598 -603
  246. package/src/interfaces/terminal/components/MessageList.js +280 -281
  247. package/src/interfaces/terminal/components/SettingsPanel.js +410 -415
  248. package/src/interfaces/terminal/components/StatusBar.js +2 -0
  249. package/src/interfaces/terminal/index.js +168 -168
  250. package/src/interfaces/terminal/state/useAgentControl.js +496 -496
  251. package/src/interfaces/terminal/state/useAgents.js +537 -537
  252. package/src/interfaces/terminal/state/useMessages.js +629 -630
  253. package/src/interfaces/terminal/state/useTools.js +554 -554
  254. package/src/interfaces/terminal/utils/debugLogger.js +44 -44
  255. package/src/interfaces/terminal/utils/settingsStorage.js +232 -232
  256. package/src/interfaces/webServer.js +7578 -7579
  257. package/src/interfaces/webServer.js.bak +7046 -7046
  258. package/src/modules/fileExplorer/__tests__/zipDownload.test.js +237 -237
  259. package/src/modules/fileExplorer/controller.js +470 -469
  260. package/src/modules/fileExplorer/routes.js +285 -286
  261. package/src/modules/widget/__tests__/isDisabled.test.js +41 -41
  262. package/src/modules/widget/__tests__/routes.test.js +677 -678
  263. package/src/modules/widget/__tests__/runtime.test.js +401 -401
  264. package/src/modules/widget/__tests__/versioning.test.js +309 -309
  265. package/src/modules/widget/__tests__/webComponentRuntime.test.js +565 -565
  266. package/src/modules/widget/__tests__/widgetTool.test.js +316 -316
  267. package/src/modules/widget/routes.js +435 -435
  268. package/src/modules/widget/runtime/bundle.js +640 -640
  269. package/src/modules/widget/runtime/webComponentBundle.js +470 -470
  270. package/src/modules/widget/schema.js +182 -181
  271. package/src/modules/widget/widgetTool.js +1389 -1389
  272. package/src/services/__tests__/agentActivityService.test.js +401 -402
  273. package/src/services/__tests__/benchmarkService.test.js +184 -184
  274. package/src/services/__tests__/contextInjectionService.test.js +246 -246
  275. package/src/services/__tests__/conversationQuery.test.js +721 -723
  276. package/src/services/__tests__/credentialVault.test.js +469 -469
  277. package/src/services/__tests__/discordService.integration.test.js +638 -639
  278. package/src/services/__tests__/flowContextService.test.js +590 -590
  279. package/src/services/__tests__/memoryService.test.js +1 -1
  280. package/src/services/__tests__/messageSource.test.js +380 -380
  281. package/src/services/__tests__/modelRouterNaming.test.js +111 -111
  282. package/src/services/__tests__/projectDetector.test.js +34 -34
  283. package/src/services/__tests__/promptService.test.js +242 -242
  284. package/src/services/__tests__/telegramService.test.js +941 -941
  285. package/src/services/__tests__/tokenCountingService.test.js +48 -48
  286. package/src/services/agentActivityService.js +419 -420
  287. package/src/services/aiService.js +2997 -3001
  288. package/src/services/apiKeyManager.js +359 -359
  289. package/src/services/benchmarkService.js +196 -196
  290. package/src/services/codebaseKnowledgeService.js +2 -2
  291. package/src/services/composioService.js +738 -738
  292. package/src/services/conversationCompactionService.js +1258 -1257
  293. package/src/services/credentialVault.js +685 -685
  294. package/src/services/discordService.js +792 -793
  295. package/src/services/embeddings/__tests__/azureCustomProvider.test.js +232 -232
  296. package/src/services/embeddings/__tests__/embeddingService.test.js +417 -417
  297. package/src/services/embeddings/__tests__/localProvider.test.js +263 -263
  298. package/src/services/embeddings/autoRecall.js +218 -219
  299. package/src/services/embeddings/indexers/__tests__/agentIndexer.test.js +232 -232
  300. package/src/services/embeddings/indexers/__tests__/memoryIndexer.test.js +418 -418
  301. package/src/services/embeddings/indexers/__tests__/reminisceIndexer.test.js +356 -357
  302. package/src/services/embeddings/indexers/__tests__/skillsIndexer.test.js +145 -145
  303. package/src/services/embeddings/indexers/__tests__/taskIndexer.test.js +146 -146
  304. package/src/services/embeddings/indexers/composioIndexer.js +279 -279
  305. package/src/services/embeddings/providerInterface.js +206 -206
  306. package/src/services/embeddings/providers/localProvider.js +11 -7
  307. package/src/services/embeddings/providers/openaiProvider.js +101 -101
  308. package/src/services/embeddings/vectorStore/inMemoryJsonStore.js +356 -356
  309. package/src/services/errorHandler.js +809 -809
  310. package/src/services/flowContextService.js +586 -586
  311. package/src/services/grounding/MockAdapter.js +125 -125
  312. package/src/services/modelRouterService.js +26 -31
  313. package/src/services/modelsService.js +322 -322
  314. package/src/services/ollamaService.js +452 -452
  315. package/src/services/projectDetector.js +403 -404
  316. package/src/services/promptService.js +418 -418
  317. package/src/services/qualityInspector.js +795 -795
  318. package/src/services/scheduleService.js +726 -726
  319. package/src/services/serviceRegistry.js +386 -386
  320. package/src/services/telegrafBot.js +174 -174
  321. package/src/services/telegramService.js +1972 -1972
  322. package/src/services/visualEditorBridge.js +1033 -1033
  323. package/src/services/visualEditorServer.js +1769 -1774
  324. package/src/services/whatsappService.js +667 -668
  325. package/src/tools/__tests__/agentCommunicationTool.findAgent.test.js +226 -226
  326. package/src/tools/__tests__/agentCommunicationTool.test.js +3 -3
  327. package/src/tools/__tests__/agentDelayTool.test.js +342 -342
  328. package/src/tools/__tests__/baseTool.test.js +3 -3
  329. package/src/tools/__tests__/codeMapTool.test.js +915 -915
  330. package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -309
  331. package/src/tools/__tests__/fileTreeTool.test.js +274 -274
  332. package/src/tools/__tests__/filesystemTool.test.js +815 -815
  333. package/src/tools/__tests__/foundryWebSearchTool.test.js +252 -252
  334. package/src/tools/__tests__/imageTool.validator.test.js +194 -194
  335. package/src/tools/__tests__/jobDoneTool.test.js +580 -581
  336. package/src/tools/__tests__/memoryTool.forgetStale.test.js +272 -272
  337. package/src/tools/__tests__/memoryTool.reminisce.test.js +2 -2
  338. package/src/tools/__tests__/memoryTool.reminisceSemanticSearch.test.js +301 -301
  339. package/src/tools/__tests__/memoryTool.semanticSearch.test.js +405 -405
  340. package/src/tools/__tests__/memoryTool.teamPool.test.js +293 -293
  341. package/src/tools/__tests__/memoryTool.test.js +1 -1
  342. package/src/tools/__tests__/seekTool.test.js +282 -282
  343. package/src/tools/__tests__/skillsTool.search.test.js +164 -164
  344. package/src/tools/__tests__/skillsTool.test.js +226 -226
  345. package/src/tools/__tests__/staticAnalysisTool.test.js +509 -509
  346. package/src/tools/__tests__/taskManagerTool.discipline.test.js +137 -137
  347. package/src/tools/__tests__/taskManagerTool.search.test.js +143 -143
  348. package/src/tools/__tests__/taskManagerTool.test.js +866 -866
  349. package/src/tools/__tests__/terminalTool.test.js +448 -448
  350. package/src/tools/__tests__/toolShapeForgiveness.test.js +259 -260
  351. package/src/tools/__tests__/userPromptTool.test.js +297 -297
  352. package/src/tools/__tests__/videoTool.jobs.test.js +147 -147
  353. package/src/tools/__tests__/webTool.e2e.test.js +609 -603
  354. package/src/tools/__tests__/webTool.unit.test.js +195 -195
  355. package/src/tools/__tests__/webTool.visionModel.test.js +75 -75
  356. package/src/tools/agentCommunicationTool.js +8 -10
  357. package/src/tools/agentDelayTool.js +496 -497
  358. package/src/tools/asyncToolManager.js +602 -603
  359. package/src/tools/baseTool.js +12 -11
  360. package/src/tools/cloneDetectionTool.js +576 -581
  361. package/src/tools/codeMapTool.js +0 -6
  362. package/src/tools/composioTool.js +617 -617
  363. package/src/tools/dependencyResolverTool.js +1211 -1212
  364. package/src/tools/desktop/DesktopTool.js +629 -638
  365. package/src/tools/desktop/__tests__/DesktopTool.e2e.test.js +306 -306
  366. package/src/tools/desktop/__tests__/DesktopTool.test.js +507 -507
  367. package/src/tools/desktop/__tests__/osController.test.js +364 -364
  368. package/src/tools/desktop/osController.js +491 -491
  369. package/src/tools/docxTool.js +623 -623
  370. package/src/tools/excelTool.js +636 -636
  371. package/src/tools/fileContentReplaceTool.js +5 -7
  372. package/src/tools/fileSystemTool.js +12 -19
  373. package/src/tools/fileTreeTool.js +840 -840
  374. package/src/tools/foundryWebSearchTool.js +273 -273
  375. package/src/tools/helpTool.js +198 -198
  376. package/src/tools/imageTool.js +1397 -1397
  377. package/src/tools/importAnalyzerTool.js +1056 -1056
  378. package/src/tools/jobDoneTool.js +495 -495
  379. package/src/tools/memoryTool.js +1 -1
  380. package/src/tools/office/pres/__tests__/presSystem.test.js +365 -365
  381. package/src/tools/office/pres/archetypes/agenda.js +61 -61
  382. package/src/tools/office/pres/archetypes/bentoGrid.js +218 -219
  383. package/src/tools/office/pres/archetypes/bigStat.js +140 -142
  384. package/src/tools/office/pres/archetypes/closing.js +70 -70
  385. package/src/tools/office/pres/archetypes/hero.js +70 -70
  386. package/src/tools/office/pres/archetypes/productHero.js +93 -94
  387. package/src/tools/office/pres/archetypes/table.js +73 -74
  388. package/src/tools/office/pres/backgrounds/orb.js +66 -66
  389. package/src/tools/office/pres/components.js +422 -423
  390. package/src/tools/officeTool.js +441 -441
  391. package/src/tools/pdfTool.js +625 -627
  392. package/src/tools/platformControlTool.js +1081 -1081
  393. package/src/tools/seekTool.js +917 -918
  394. package/src/tools/skillsTool.js +1 -1
  395. package/src/tools/staticAnalysisTool.js +2143 -2146
  396. package/src/tools/taskManagerTool.js +3324 -3324
  397. package/src/tools/terminalTool.js +2615 -2618
  398. package/src/tools/videoTool.js +1303 -1303
  399. package/src/tools/visionTool.js +508 -508
  400. package/src/tools/visualEditorTool.js +1289 -1290
  401. package/src/tools/webTool.js +3368 -3368
  402. package/src/tools/whatsappTool.js +464 -464
  403. package/src/types/__tests__/agent.test.js +499 -499
  404. package/src/types/__tests__/contextReference.test.js +606 -606
  405. package/src/types/__tests__/conversation.test.js +555 -555
  406. package/src/types/__tests__/toolCommand.test.js +584 -584
  407. package/src/types/contextReference.js +974 -971
  408. package/src/types/conversation.js +729 -729
  409. package/src/types/toolCommand.js +746 -746
  410. package/src/utilities/__tests__/attachmentValidator.test.js +80 -80
  411. package/src/utilities/__tests__/auditReport.test.js +328 -328
  412. package/src/utilities/__tests__/directoryAccessManager.test.js +388 -388
  413. package/src/utilities/__tests__/jsonRepair.test.js +103 -104
  414. package/src/utilities/__tests__/modeTransitionReasons.test.js +105 -105
  415. package/src/utilities/__tests__/platformUtils.test.js +80 -87
  416. package/src/utilities/__tests__/structuredFileValidator.test.js +261 -263
  417. package/src/utilities/__tests__/toolConstants.test.js +92 -94
  418. package/src/utilities/__tests__/useIsTouchDevice.detect.test.js +114 -114
  419. package/src/utilities/__tests__/webUiUtilSync.test.js +117 -117
  420. package/src/utilities/attachmentValidator.js +284 -288
  421. package/src/utilities/authCache.js.backup-1779570472481 +121 -121
  422. package/src/utilities/browserStealth.js +631 -630
  423. package/src/utilities/configManager.js +616 -617
  424. package/src/utilities/directoryAccessManager.js +564 -565
  425. package/src/utilities/fileProcessor.js +308 -307
  426. package/src/utilities/humanBehavior.js +454 -453
  427. package/src/utilities/logger.js +479 -479
  428. package/src/utilities/structuredFileValidator.js +696 -699
  429. package/src/utilities/tagParser.js +5 -10
  430. package/src/utilities/userDataDir.js +308 -308
  431. package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.js.map +0 -1
  432. package/node_modules/@isaacs/brace-expansion/dist/esm/index.js.map +0 -1
  433. package/node_modules/minipass/LICENSE +0 -15
  434. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/LICENSE.md +0 -0
  435. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts +0 -0
  436. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts.map +0 -0
  437. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js +0 -0
  438. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js.map +0 -0
  439. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/package.json +0 -0
  440. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts +0 -0
  441. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts.map +0 -0
  442. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js +0 -0
  443. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js.map +0 -0
  444. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/package.json +0 -0
  445. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/LICENSE +0 -0
  446. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts +0 -0
  447. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts.map +0 -0
  448. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/package.json +0 -0
  449. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts +0 -0
  450. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts.map +0 -0
  451. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/package.json +0 -0
@@ -1,1397 +1,1397 @@
1
- /**
2
- * @file tools/imageTool.js
3
- * @description Tool for generating images using AI models (resolved dynamically from backend)
4
- */
5
-
6
- import path from 'path';
7
- import os from 'os';
8
- import { promises as fs } from 'fs';
9
- import { BaseTool } from './baseTool.js';
10
- import { getGalleryService } from '../services/galleryService.js';
11
- import {
12
- resolveTargetFormat,
13
- transcodeIfNeeded,
14
- extensionFor,
15
- ensureExtension,
16
- DEFAULT_FORMAT,
17
- } from './imageFormat.js';
18
-
19
- /**
20
- * Configuration constants for image generation
21
- */
22
- const IMAGE_CONFIG = {
23
- DEFAULT_MODEL: null, // Resolved dynamically from modelsService via aiService
24
- DEFAULT_SIZE: '1024x1024',
25
- DEFAULT_QUALITY: 'standard',
26
- // Flux-family size rules (used when the effective model matches FLUX_MODELS).
27
- SIZE_MIN: 256,
28
- SIZE_MAX: 1440,
29
- SIZE_INCREMENT: 32, // Must be multiples of 32
30
-
31
- /**
32
- * Per-model size rules. Two kinds of rule:
33
- * { kind: 'enum', values: ['1024x1024', ...] } — exact match required
34
- * { kind: 'range', min, max, increment } — W×H in grid
35
- * Key matching is substring, case-insensitive (so 'gpt-image-1.5' matches
36
- * 'gpt-image-1.5-preview', etc.). The first matching entry wins; anything
37
- * else falls back to `default`.
38
- *
39
- * This table is the SINGLE SOURCE OF TRUTH for what the tool accepts
40
- * client-side. Previously the validator was hardcoded to Flux rules and
41
- * actively rejected valid gpt-image-1.5 sizes (1024x1536, 1536x1024, auto)
42
- * while the tool's own docstring advertised them. See _validateParameters.
43
- */
44
- MODEL_SIZES: {
45
- 'gpt-image-1.5': { kind: 'enum', values: ['1024x1024', '1024x1536', '1536x1024', 'auto'] },
46
- 'gpt-image-1': { kind: 'enum', values: ['1024x1024', '1024x1536', '1536x1024', 'auto'] },
47
- 'flux': { kind: 'range', min: 256, max: 1440, increment: 32 },
48
- 'default': { kind: 'range', min: 256, max: 1440, increment: 32 },
49
- },
50
-
51
- /**
52
- * Size presets, model-aware. Each entry is a shape key; the resolver
53
- * picks the variant for the effective model. If the model isn't listed,
54
- * the `default` variant is used (Flux-shaped).
55
- *
56
- * Agents were previously confused because `portrait` → 1024x1440 always,
57
- * even when the tool silently auto-switched to gpt-image-1.5 (where that
58
- * size is invalid). Now `portrait` resolves to 1024x1536 for gpt-image.
59
- */
60
- SIZE_PRESETS: {
61
- 'square': { default: '1024x1024', 'gpt-image-1.5': '1024x1024' },
62
- 'square_hd': { default: '1440x1440', 'gpt-image-1.5': '1024x1024' },
63
- 'portrait': { default: '1024x1440', 'gpt-image-1.5': '1024x1536' },
64
- 'landscape': { default: '1440x1024', 'gpt-image-1.5': '1536x1024' },
65
- 'portrait_4_3': { default: '768x1024', 'gpt-image-1.5': '1024x1536' },
66
- 'landscape_4_3': { default: '1024x768', 'gpt-image-1.5': '1536x1024' },
67
- 'small': { default: '512x512', 'gpt-image-1.5': '1024x1024' },
68
- },
69
-
70
- VALID_FORMATS: ['png', 'jpg', 'jpeg', 'webp'],
71
- MAX_CONCURRENT: 3,
72
- QUEUE_LIMIT: 10,
73
- TEMP_CLEANUP_MS: 3600000, // 1 hour
74
- MAX_PROMPT_LENGTH: 4000,
75
- DOWNLOAD_TIMEOUT: 60000 // 60 seconds
76
- };
77
-
78
- /**
79
- * Resolve the effective model given the caller's inputs.
80
- *
81
- * The tool auto-switches to `gpt-image-1.5` when the caller asks for
82
- * transparency or supplies a sourceImage (only gpt-image-1.5 supports
83
- * those features). Previously this logic lived inside the async worker,
84
- * AFTER _validateParameters ran — so validation applied Flux rules even
85
- * when the real model would end up being gpt-image-1.5 and vice versa.
86
- * Hoisted here so validators, error messages, and preset resolution all
87
- * see the same truth.
88
- *
89
- * @param {object} params - imageParams with { model, transparency, sourceImage }
90
- * @returns {string} — the effective model id (lowercase)
91
- */
92
- export function resolveEffectiveImageModel(params = {}) {
93
- const explicit = (params.model || '').toLowerCase();
94
- if (explicit && explicit !== 'auto') return explicit;
95
- if (params.transparency === true) return 'gpt-image-1.5';
96
- if (params.sourceImage) return 'gpt-image-1.5';
97
- // Null → defer to downstream (aiService resolves from catalog). We
98
- // still return a sentinel so validators can use `default` rules.
99
- return '';
100
- }
101
-
102
- /**
103
- * Translate an image-generation error into an actionable message for the
104
- * agent. The backend (`aiService._generateImageOpenAI` / Flux) wraps HTTP
105
- * responses as `"HTTP 400: Bad Request - <provider text>"` and throws.
106
- * That passes through to the agent as raw stack text — agents don't know
107
- * whether they tripped a size rule, a moderation rule, or something else.
108
- *
109
- * This helper parses the incoming `Error` for the common signatures and
110
- * produces a message that includes (a) the effective model, (b) what
111
- * class of failure it was, and (c) a concrete next-action.
112
- *
113
- * @param {Error} error - Thrown from aiService / downstream
114
- * @param {object} job - The queued job (provides model + size + prompt)
115
- * @returns {string}
116
- */
117
- export function _translateImageError(error, job = {}) {
118
- const raw = (error && error.message) || String(error || 'unknown error');
119
- const effectiveModel = resolveEffectiveImageModel(job) || job.model || 'auto';
120
- const size = job.size || IMAGE_CONFIG.DEFAULT_SIZE;
121
- const lower = raw.toLowerCase();
122
-
123
- // Content moderation family — providers use various phrasings.
124
- if (/moderat|content[_ -]policy|safety|flagged|unsafe/.test(lower)) {
125
- return (
126
- `Image generation rejected by content moderation (model=${effectiveModel}). ` +
127
- `The prompt was flagged. Rephrase rather than retrying verbatim: soften explicit ` +
128
- `adjectives (e.g. drop "blood", "gore", "bones"), describe style instead of violence, ` +
129
- `and avoid real-person likenesses. Original provider message: ${raw}`
130
- );
131
- }
132
-
133
- // Size-shaped rejections from the provider (leaks through when our
134
- // client-side validator missed, or when the catalogue changes).
135
- if (/size|dimension|unsupported|out of range|must be one of|invalid image size/.test(lower)) {
136
- const { rule } = _getModelSizeRule(effectiveModel);
137
- const valid = rule.kind === 'enum'
138
- ? `Valid sizes for ${effectiveModel}: ${rule.values.join(', ')}.`
139
- : `Valid for ${effectiveModel}: WIDTHxHEIGHT in ${rule.min}-${rule.max}, multiples of ${rule.increment}.`;
140
- return (
141
- `Image generation rejected: size "${size}" not accepted by ${effectiveModel}. ${valid} ` +
142
- `Original provider message: ${raw}`
143
- );
144
- }
145
-
146
- // Auth / quota.
147
- if (/unauthorized|forbidden|quota|rate.?limit|429|401|403/.test(lower)) {
148
- return (
149
- `Image generation failed for ${effectiveModel}: authentication or quota problem. ` +
150
- `Retrying with the same inputs will likely fail again — check API keys / billing. ` +
151
- `Original: ${raw}`
152
- );
153
- }
154
-
155
- // Fall-through: still tell the agent which model ran.
156
- return `Image generation failed (model=${effectiveModel}, size=${size}): ${raw}`;
157
- }
158
-
159
- /**
160
- * Look up the size rule for an effective model id. Substring match.
161
- * Always returns a rule (falls back to `default`).
162
- */
163
- export function _getModelSizeRule(effectiveModel) {
164
- const key = String(effectiveModel || '').toLowerCase();
165
- for (const [needle, rule] of Object.entries(IMAGE_CONFIG.MODEL_SIZES)) {
166
- if (needle === 'default') continue;
167
- if (key.includes(needle)) return { modelKey: needle, rule };
168
- }
169
- return { modelKey: 'default', rule: IMAGE_CONFIG.MODEL_SIZES.default };
170
- }
171
-
172
- /**
173
- * ImageTool - Generate images using AI models
174
- * Supports queueing, async processing, and both temp/project directory storage
175
- */
176
- export class ImageTool extends BaseTool {
177
- constructor(config = {}, logger = null) {
178
- super(config, logger);
179
-
180
- // Override tool ID
181
- this.id = 'image-gen';
182
-
183
- // Job queue and tracking
184
- this.queue = [];
185
- this.currentJob = null;
186
- this.completedJobs = new Map();
187
- this.isProcessing = false;
188
-
189
- // AIService will be injected later
190
- this.aiService = null;
191
-
192
- // AgentPool will be injected later (for saving to conversation history)
193
- this.agentPool = null;
194
-
195
- // Temp directory for images
196
- this.tempDir = path.join(os.tmpdir(), 'loxia-images');
197
-
198
- // Cleanup timers
199
- this.cleanupTimers = new Map();
200
- }
201
-
202
- /**
203
- * Set AI service for image generation
204
- * @param {AIService} aiService - AI service instance
205
- */
206
- setAIService(aiService) {
207
- this.aiService = aiService;
208
- this.logger?.info('AI Service set for ImageTool');
209
- }
210
-
211
- /**
212
- * Set Agent Pool for saving results to conversation history
213
- * @param {AgentPool} agentPool - AgentPool instance
214
- */
215
- setAgentPool(agentPool) {
216
- this.agentPool = agentPool;
217
- this.logger?.info('AgentPool set for ImageTool');
218
- }
219
-
220
- /**
221
- * Get tool description for agent system prompt
222
- * @returns {string} Formatted tool description
223
- */
224
- getDescription() {
225
- return `Tool: Image Generator - Generate and edit images using AI models
226
-
227
- **Purpose:** Generate images from text descriptions, edit existing images, and create images with transparency. Images are saved to files and displayed in chat.
228
-
229
- **CRITICAL: Automatic Execution**
230
- - ANY \`\`\`json block with "toolId": "image-gen" will be EXECUTED IMMEDIATELY
231
- - Just output the command when you want to generate or edit an image
232
- - If generation fails, output a NEW command with corrections
233
-
234
- **USAGE — Generate (default WebP):**
235
- \`\`\`json
236
- {
237
- "toolId": "image-gen",
238
- "parameters": {
239
- "prompt": "Detailed description of the image",
240
- "outputPath": "images/filename.webp",
241
- "size": "1024x1024"
242
- }
243
- }
244
- \`\`\`
245
-
246
- **USAGE — Force PNG output (when you specifically need lossless / older-tool compat):**
247
- \`\`\`json
248
- {
249
- "toolId": "image-gen",
250
- "parameters": {
251
- "prompt": "Pixel-art icon set on white",
252
- "outputPath": "images/icons.png"
253
- }
254
- }
255
- \`\`\`
256
- Or, equivalently, with the explicit field:
257
- \`\`\`json
258
- {
259
- "toolId": "image-gen",
260
- "parameters": { "prompt": "Pixel-art icon set", "outputType": "png" }
261
- }
262
- \`\`\`
263
-
264
- **USAGE — Generate with Transparency (WebP supports alpha):**
265
- \`\`\`json
266
- {
267
- "toolId": "image-gen",
268
- "parameters": {
269
- "prompt": "A cute fox mascot on a transparent background",
270
- "model": "gpt-image-1.5",
271
- "transparency": true,
272
- "outputPath": "images/fox-mascot.webp"
273
- }
274
- }
275
- \`\`\`
276
-
277
- **USAGE — Edit Existing Image:**
278
- \`\`\`json
279
- {
280
- "toolId": "image-gen",
281
- "parameters": {
282
- "prompt": "Remove the background and make it transparent",
283
- "sourceImage": "images/photo.png",
284
- "model": "gpt-image-1.5",
285
- "outputPath": "images/photo-nobg.webp"
286
- }
287
- }
288
- \`\`\`
289
- (The \`sourceImage\` can be any common format — PNG, JPEG, WebP, etc. The OUTPUT format is whatever you specify on \`outputPath\` / \`outputType\`, defaulting to WebP.)
290
-
291
- **Parameters:**
292
- - **prompt** (required): Description of image to generate OR editing instruction
293
- - **outputPath** (optional): Path to save image (permanent). Omit for temp file.
294
- The file extension determines output format (.webp / .png / .jpg / .jpeg).
295
- - **outputType** (optional): Explicit format request when outputPath has no extension
296
- or is omitted: 'webp' | 'png' | 'jpg' | 'jpeg'. Default: 'webp'.
297
- An outputPath extension always wins over outputType if both are present.
298
- - **size** (optional): WIDTHxHEIGHT or preset name. DIFFERENT MODELS ACCEPT DIFFERENT SIZES — read the rules below. Default: 1024x1024.
299
- - **quality** (optional): "standard" or "hd" (default: standard)
300
- - **model** (optional): "gpt-image-1.5" or a flux variant. Default: auto. Auto-switches to gpt-image-1.5 if transparency=true OR sourceImage is set.
301
- - **sourceImage** (optional): Path to source image for editing (forces gpt-image-1.5)
302
- - **mask** (optional): Path to mask image for editing (white=edit area, black=keep area)
303
- - **transparency** (optional): true to generate with transparent background (forces gpt-image-1.5)
304
-
305
- **Output format (NEW default: WebP):**
306
- The tool transcodes provider bytes (typically PNG) into WebP by default —
307
- smaller files, same visual quality. Force PNG with either an explicit
308
- .png extension on outputPath OR \`outputType: "png"\`. Pick PNG when you
309
- need lossless graphics or animated GIFs would otherwise be needed; WebP
310
- is the better default for photo-style outputs.
311
-
312
- ⚠ SIZE RULES — read before picking a size:
313
-
314
- (A) gpt-image-1.5 (auto-selected on transparency=true or sourceImage, or set explicitly):
315
- size MUST be one of: 1024x1024, 1024x1536 (portrait), 1536x1024 (landscape), or "auto".
316
- Any other value is rejected; the error lists the valid options.
317
- Presets remap automatically — e.g. "portrait" → 1024x1536 on this model.
318
-
319
- (B) Flux models (the default when transparency is not required):
320
- size must be WIDTHxHEIGHT, both in 256-1440, both multiples of 32 (e.g. 1024x768, 1280x960).
321
- "auto" is NOT valid for Flux.
322
-
323
- Presets (shape names — resolved per the chosen model):
324
- square, square_hd, portrait, landscape, portrait_4_3, landscape_4_3, small
325
-
326
- Content moderation:
327
- - Prompts with graphic violence, gore, explicit sexual content, or real-person likenesses are commonly rejected by the provider as "Bad request". If you hit one of those, REPHRASE — don't retry verbatim. Soften explicit adjectives (e.g. "dark fantasy queen with crimson rubies" works; "blood tears, bone crown, dripping" typically doesn't).
328
-
329
- **Notes:**
330
- - Images take 15-30 seconds to generate
331
- - Be descriptive in prompts for better results
332
- - Use gpt-image-1.5 when you need transparent backgrounds, image editing, or image-to-image transformations
333
- - Max ${IMAGE_CONFIG.QUEUE_LIMIT} images in queue`;
334
- }
335
-
336
- /**
337
- * Parse image generation parameters
338
- * @param {string|Object} content - Raw content or parsed object
339
- * @returns {Object} Parsed parameters
340
- */
341
- parseParameters(content) {
342
- // Handle JSON format
343
- if (typeof content === 'object' && content !== null) {
344
- return this._parseJSONParams(content);
345
- }
346
-
347
- // Handle string format
348
- if (typeof content === 'string') {
349
- const trimmed = content.trim();
350
-
351
- // Try to parse as JSON first
352
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
353
- try {
354
- const parsed = JSON.parse(trimmed);
355
- return this._parseJSONParams(parsed);
356
- } catch (err) {
357
- // Not valid JSON, fall through to XML parsing
358
- }
359
- }
360
-
361
- // Parse as XML
362
- return this._parseXMLParams(content);
363
- }
364
-
365
- throw new Error('Invalid parameter format');
366
- }
367
-
368
- /**
369
- * Parse JSON parameters
370
- * @private
371
- */
372
- _parseJSONParams(obj) {
373
- // Handle parameters wrapper (when called via toolId/parameters structure)
374
- if (obj.parameters) {
375
- obj = obj.parameters;
376
- }
377
-
378
- // Check for batch mode
379
- if (obj.batch && Array.isArray(obj.batch)) {
380
- return {
381
- batch: true,
382
- images: obj.batch.map(img => this._parseImageParams(img))
383
- };
384
- }
385
-
386
- return {
387
- batch: false,
388
- images: [this._parseImageParams(obj)]
389
- };
390
- }
391
-
392
- /**
393
- * Parse XML parameters
394
- * @private
395
- */
396
- _parseXMLParams(content) {
397
- const params = { batch: false, images: [] };
398
-
399
- // Check for batch mode
400
- const batchMatch = /<batch>([\s\S]*?)<\/batch>/i.exec(content);
401
-
402
- if (batchMatch) {
403
- params.batch = true;
404
- const batchContent = batchMatch[1];
405
-
406
- // Extract individual <image> blocks
407
- const imageRegex = /<image>([\s\S]*?)<\/image>/gi;
408
- let match;
409
-
410
- while ((match = imageRegex.exec(batchContent)) !== null) {
411
- params.images.push(this._parseXMLImage(match[1]));
412
- }
413
- } else {
414
- // Single image mode
415
- params.images.push(this._parseXMLImage(content));
416
- }
417
-
418
- if (params.images.length === 0) {
419
- throw new Error('No valid image parameters found');
420
- }
421
-
422
- return params;
423
- }
424
-
425
- /**
426
- * Parse single image parameters from object
427
- * @private
428
- */
429
- _parseImageParams(obj) {
430
- const outputPath = obj.outputPath || obj['output-path'] || null;
431
- // Optional explicit format request. Accepts 'webp' (default if no
432
- // outputPath ext), 'png', 'jpg' / 'jpeg'. The outputPath extension
433
- // wins if both are present (handled in resolveTargetFormat).
434
- const outputType = obj.outputType || obj['output-type'] || obj.format || null;
435
-
436
- return {
437
- prompt: obj.prompt || '',
438
- outputPath: outputPath,
439
- outputType: outputType,
440
- saveToProject: outputPath !== null, // If path specified, save to project
441
- model: obj.model || IMAGE_CONFIG.DEFAULT_MODEL,
442
- size: obj.size || IMAGE_CONFIG.DEFAULT_SIZE,
443
- quality: obj.quality || IMAGE_CONFIG.DEFAULT_QUALITY,
444
- sourceImage: obj.sourceImage || null, // Edit mode: path to source image
445
- mask: obj.mask || null, // Edit mode: path to mask image
446
- transparency: obj.transparency || false // Generate with transparent background
447
- };
448
- }
449
-
450
- /**
451
- * Parse single image parameters from XML string
452
- * @private
453
- */
454
- _parseXMLImage(xmlContent) {
455
- const extractTag = (tag) => {
456
- const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i');
457
- const match = regex.exec(xmlContent);
458
- return match ? match[1].trim() : null;
459
- };
460
-
461
- const outputPath = extractTag('output-path') || null;
462
- const outputType = extractTag('output-type') || extractTag('format') || null;
463
-
464
- return {
465
- prompt: extractTag('prompt') || '',
466
- outputPath: outputPath,
467
- outputType: outputType,
468
- saveToProject: outputPath !== null, // If path specified, save to project
469
- model: extractTag('model') || IMAGE_CONFIG.DEFAULT_MODEL,
470
- size: extractTag('size') || IMAGE_CONFIG.DEFAULT_SIZE,
471
- quality: extractTag('quality') || IMAGE_CONFIG.DEFAULT_QUALITY,
472
- sourceImage: extractTag('source-image') || null,
473
- mask: extractTag('mask') || null,
474
- transparency: extractTag('transparency') === 'true'
475
- };
476
- }
477
-
478
- /**
479
- * Execute image generation
480
- * @param {Object|string} params - Parsed parameters object OR raw XML/JSON string
481
- * @param {Object} context - Execution context
482
- * @returns {Promise<Object>} Execution result
483
- */
484
- async execute(params, context = {}) {
485
- try {
486
- const { agentId, projectDir, directoryAccess, sessionId } = context;
487
-
488
- // Auto-detect and parse inputs (from TagParser or direct call)
489
- // parseParameters() normalizes input to { batch: bool, images: [...] } format
490
- if (typeof params === 'string') {
491
- this.logger?.info('ImageTool: Auto-parsing string parameters');
492
- params = this.parseParameters(params);
493
- } else if (typeof params === 'object' && params !== null && !params.images) {
494
- // Object params without 'images' array - needs parsing to normalize structure
495
- this.logger?.info('ImageTool: Normalizing object parameters');
496
- params = this.parseParameters(params);
497
- }
498
-
499
- // Validate parameters
500
- this._validateParameters(params);
501
-
502
- // Queue images
503
- const jobIds = [];
504
-
505
- for (const imageParams of params.images) {
506
- // Create job
507
- const jobId = this._generateJobId();
508
-
509
- const job = {
510
- jobId,
511
- agentId,
512
- sessionId,
513
- prompt: imageParams.prompt,
514
- outputPath: imageParams.outputPath,
515
- // Explicit format request — used by the save path to decide the
516
- // target format. Default behavior (no outputPath, no outputType):
517
- // WebP. See imageFormat.resolveTargetFormat for precedence.
518
- outputType: imageParams.outputType || null,
519
- saveToProject: imageParams.saveToProject,
520
- model: imageParams.model,
521
- size: imageParams.size,
522
- quality: imageParams.quality,
523
- projectDir: projectDir || process.cwd(),
524
- directoryAccess,
525
- // Carry the caller's per-agent image-gen config onto the job
526
- // so the async processor (which doesn't have the original
527
- // context) can still honor `saveToGallery` etc.
528
- toolConfig: context?.toolConfig || null,
529
- status: 'queued',
530
- createdAt: new Date().toISOString()
531
- };
532
-
533
- // Check queue limit
534
- if (this.queue.length >= IMAGE_CONFIG.QUEUE_LIMIT) {
535
- return {
536
- success: false,
537
- error: `Queue limit reached (${IMAGE_CONFIG.QUEUE_LIMIT} images). Please wait for current jobs to complete.`,
538
- queueLength: this.queue.length
539
- };
540
- }
541
-
542
- this.queue.push(job);
543
- jobIds.push(jobId);
544
-
545
- this.logger?.info(`Image generation job queued: ${jobId}`, {
546
- prompt: imageParams.prompt.substring(0, 50) + '...',
547
- queuePosition: this.queue.length
548
- });
549
- }
550
-
551
- // Start processing if not already running
552
- if (!this.isProcessing) {
553
- this._processQueue().catch(err => {
554
- this.logger?.error('Queue processing error:', err);
555
- });
556
- }
557
-
558
- // Return immediate response
559
- return {
560
- success: true,
561
- jobIds,
562
- queueLength: this.queue.length,
563
- message: params.batch
564
- ? `${jobIds.length} images queued for generation`
565
- : 'Image queued for generation',
566
- estimatedWaitTime: this._estimateWaitTime()
567
- };
568
-
569
- } catch (error) {
570
- this.logger?.error('Image generation error:', error);
571
- return {
572
- success: false,
573
- error: error.message
574
- };
575
- }
576
- }
577
-
578
- /**
579
- * Validate parameters
580
- * @private
581
- */
582
- _validateParameters(params) {
583
- if (!params.images || params.images.length === 0) {
584
- throw new Error('No images specified');
585
- }
586
-
587
- for (const img of params.images) {
588
- if (!img.prompt || img.prompt.trim().length === 0) {
589
- throw new Error('Image prompt is required');
590
- }
591
-
592
- if (img.prompt.length > IMAGE_CONFIG.MAX_PROMPT_LENGTH) {
593
- throw new Error(`Prompt too long (max ${IMAGE_CONFIG.MAX_PROMPT_LENGTH} characters)`);
594
- }
595
-
596
- // Resolve the effective model FIRST so size validation, preset
597
- // resolution, and error messages all see the model that will
598
- // actually run. Previously this logic lived downstream in the
599
- // async worker so Flux rules were applied even when the tool
600
- // would silently switch to gpt-image-1.5 (transparency=true,
601
- // sourceImage set, or explicit model) — and vice versa.
602
- const effectiveModel = resolveEffectiveImageModel(img);
603
- const { modelKey: ruleKey, rule } = _getModelSizeRule(effectiveModel);
604
-
605
- if (img.size) {
606
- // Step 1: expand preset names. Presets are model-aware — e.g.
607
- // `portrait` is 1024x1440 on Flux but 1024x1536 on gpt-image-1.5
608
- // (the latter only accepts specific enum values). Previously the
609
- // preset was resolved Flux-shaped and then rejected downstream.
610
- const presetEntry = IMAGE_CONFIG.SIZE_PRESETS[img.size];
611
- if (presetEntry) {
612
- img.size = presetEntry[ruleKey] || presetEntry.default;
613
- }
614
-
615
- // Step 2: per-model rule check.
616
- if (rule.kind === 'enum') {
617
- // gpt-image-1.5 et al — exact enum.
618
- if (!rule.values.includes(img.size)) {
619
- const modelLabel = effectiveModel || 'gpt-image-1.5';
620
- const autoHint = img.transparency
621
- ? ' (auto-selected because transparency=true)'
622
- : img.sourceImage
623
- ? ' (auto-selected because sourceImage was provided)'
624
- : '';
625
- throw new Error(
626
- `Image size "${img.size}" is not supported by ${modelLabel}${autoHint}. ` +
627
- `Valid sizes: ${rule.values.join(', ')}. ` +
628
- `For portrait orientation use "1024x1536"; for landscape use "1536x1024".`
629
- );
630
- }
631
- } else {
632
- // Range rule (Flux and default). `auto` is an enum-only value
633
- // that makes no sense for a range model — reject with a hint.
634
- if (img.size === 'auto') {
635
- throw new Error(
636
- `Image size "auto" is only valid for gpt-image-1.5. ` +
637
- `For the current model (${effectiveModel || 'flux'}) pick a WIDTHxHEIGHT ` +
638
- `between ${rule.min} and ${rule.max} in ${rule.increment}-pixel steps ` +
639
- `(e.g. 1024x1024).`
640
- );
641
- }
642
- const sizeMatch = img.size.match(/^(\d+)x(\d+)$/);
643
- if (!sizeMatch) {
644
- throw new Error(
645
- `Invalid size format: "${img.size}". ` +
646
- `Use WIDTHxHEIGHT (e.g. 1024x768) or one of these presets: ` +
647
- `${Object.keys(IMAGE_CONFIG.SIZE_PRESETS).join(', ')}.`
648
- );
649
- }
650
- const width = parseInt(sizeMatch[1], 10);
651
- const height = parseInt(sizeMatch[2], 10);
652
- const { min, max, increment } = rule;
653
- const modelLabel = effectiveModel || 'flux';
654
- if (width < min || width > max) {
655
- throw new Error(
656
- `Width ${width} out of range for ${modelLabel}. Must be ${min}-${max}px ` +
657
- `(multiple of ${increment}).`
658
- );
659
- }
660
- if (height < min || height > max) {
661
- throw new Error(
662
- `Height ${height} out of range for ${modelLabel}. Must be ${min}-${max}px ` +
663
- `(multiple of ${increment}).`
664
- );
665
- }
666
- if (width % increment !== 0) {
667
- throw new Error(
668
- `Width ${width} must be a multiple of ${increment} for ${modelLabel}. ` +
669
- `Nearest valid: ${Math.round(width / increment) * increment}.`
670
- );
671
- }
672
- if (height % increment !== 0) {
673
- throw new Error(
674
- `Height ${height} must be a multiple of ${increment} for ${modelLabel}. ` +
675
- `Nearest valid: ${Math.round(height / increment) * increment}.`
676
- );
677
- }
678
- }
679
- }
680
-
681
- if (img.outputPath) {
682
- const ext = path.extname(img.outputPath).toLowerCase().replace('.', '');
683
- if (ext && !IMAGE_CONFIG.VALID_FORMATS.includes(ext)) {
684
- throw new Error(`Invalid format: ${ext}. Valid formats: ${IMAGE_CONFIG.VALID_FORMATS.join(', ')}`);
685
- }
686
- }
687
- }
688
- }
689
-
690
- /**
691
- * Process the image generation queue
692
- * @private
693
- */
694
- async _processQueue() {
695
- if (this.isProcessing) {
696
- return;
697
- }
698
-
699
- this.isProcessing = true;
700
-
701
- while (this.queue.length > 0) {
702
- const job = this.queue.shift();
703
- this.currentJob = job;
704
-
705
- this.logger?.info(`Processing image generation job: ${job.jobId}`);
706
-
707
- try {
708
- job.status = 'processing';
709
-
710
- // Generate the image
711
- const result = await this._generateImage(job);
712
-
713
- job.status = 'completed';
714
- job.result = result;
715
- job.completedAt = new Date().toISOString();
716
-
717
- // Store completed job
718
- this.completedJobs.set(job.jobId, job);
719
-
720
- // Broadcast result via WebSocket
721
- if (global.loxiaWebServer && job.sessionId) {
722
- // Determine which URL to use: local saved file or temporary AI URL
723
- let imageUrl;
724
- let isTemporary = false;
725
-
726
- this.logger?.info('Image generation result', {
727
- jobId: job.jobId,
728
- savedToDisk: result.savedToDisk,
729
- resolvedOutputPath: result.resolvedOutputPath,
730
- temporaryUrl: result.temporaryUrl?.substring(0, 80),
731
- downloadError: result.downloadError,
732
- isBase64Response: result.isBase64Response
733
- });
734
-
735
- if (result.savedToDisk && result.resolvedOutputPath) {
736
- // Image was saved successfully - use our server endpoint
737
- imageUrl = this._convertToWebUrl(result.resolvedOutputPath, job.sessionId);
738
- this.logger?.info('Using local server URL for image', { imageUrl });
739
-
740
- // Durable gallery copy (non-blocking, non-fatal).
741
- // Gated by per-agent `toolConfig.image-gen.saveToGallery`.
742
- // Default is ON — users who want to keep disk usage down can
743
- // opt out via the image-gen configurator in the agent modal.
744
- const saveToGallery = job.toolConfig?.saveToGallery !== false;
745
- if (saveToGallery) {
746
- try {
747
- let agentName = null;
748
- if (this.agentPool && job.agentId) {
749
- try {
750
- const a = await this.agentPool.getAgent(job.agentId);
751
- agentName = a?.name || null;
752
- } catch { /* non-fatal */ }
753
- }
754
- await getGalleryService(this.logger).saveImage({
755
- sourcePath: result.resolvedOutputPath,
756
- metadata: {
757
- prompt: job.prompt,
758
- model: job.model,
759
- size: job.size,
760
- quality: job.quality,
761
- agentId: job.agentId,
762
- agentName,
763
- sessionId: job.sessionId,
764
- jobId: job.jobId,
765
- createdAt: job.completedAt || job.createdAt,
766
- },
767
- });
768
- } catch (galErr) {
769
- this.logger?.warn?.('Gallery save failed (non-fatal)', {
770
- jobId: job.jobId,
771
- error: galErr.message,
772
- });
773
- }
774
- }
775
- } else if (result.temporaryUrl) {
776
- // Download failed - use temporary AI-generated URL (expires in ~1 hour)
777
- imageUrl = result.temporaryUrl;
778
- isTemporary = true;
779
- this.logger?.warn('Using temporary AI URL for image (local save failed)', {
780
- imageUrl: imageUrl.substring(0, 80),
781
- downloadError: result.downloadError
782
- });
783
- } else {
784
- this.logger?.error('No image URL available - neither local save nor temporary URL', {
785
- jobId: job.jobId,
786
- savedToDisk: result.savedToDisk,
787
- hasTemporaryUrl: !!result.temporaryUrl
788
- });
789
- }
790
-
791
- global.loxiaWebServer.broadcastToSession(job.sessionId, {
792
- type: 'imageGenerated',
793
- agentId: job.agentId,
794
- jobId: job.jobId,
795
- imageUrl,
796
- localPath: result.resolvedOutputPath,
797
- prompt: job.prompt,
798
- success: true,
799
- isTemporary, // Indicates if URL will expire
800
- savedToDisk: result.savedToDisk,
801
- downloadError: result.downloadError, // Include error if save failed
802
- timestamp: job.completedAt
803
- });
804
-
805
- this.logger?.info('Image generation broadcast sent', {
806
- jobId: job.jobId,
807
- imageUrl,
808
- localPath: result.resolvedOutputPath,
809
- savedToDisk: result.savedToDisk,
810
- isTemporary
811
- });
812
-
813
- // Save image result to conversation history for persistence
814
- if (this.agentPool && job.agentId) {
815
- try {
816
- const agent = await this.agentPool.getAgent(job.agentId);
817
- if (agent) {
818
- // Build message content with warnings if applicable
819
- let content = `Image generated: ${job.prompt}`;
820
-
821
- if (isTemporary) {
822
- content += '\n\n⚠️ **Warning:** Image is using a temporary URL (expires in ~1 hour). Failed to save to disk.';
823
- if (result.downloadError) {
824
- content += `\n**Error:** ${result.downloadError}`;
825
- }
826
- }
827
-
828
- // Create image message for conversation history
829
- const imageMessage = {
830
- id: `img-result-${job.jobId}`,
831
- role: 'assistant',
832
- content,
833
- timestamp: job.completedAt,
834
- imageUrl, // CRITICAL: Include imageUrl so it persists across page refreshes
835
- type: 'image-result',
836
- toolId: 'image-gen',
837
- status: 'completed',
838
- isTemporary: isTemporary || false,
839
- savedToDisk: result.savedToDisk !== false
840
- };
841
-
842
- // Add to full conversation
843
- agent.conversations.full.messages.push(imageMessage);
844
- agent.conversations.full.lastUpdated = job.completedAt;
845
-
846
- // Add to current model conversation if exists
847
- if (agent.currentModel && agent.conversations[agent.currentModel]) {
848
- agent.conversations[agent.currentModel].messages.push(imageMessage);
849
- agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
850
- }
851
-
852
- // Update agent activity
853
- agent.lastActivity = job.completedAt;
854
-
855
- // Persist agent state to save conversation history
856
- await this.agentPool.persistAgentState(job.agentId);
857
-
858
- this.logger?.info('Image result saved to conversation history', {
859
- agentId: job.agentId,
860
- jobId: job.jobId,
861
- messageId: imageMessage.id
862
- });
863
-
864
- // Queue tool result so agent "sees" the completion and can continue
865
- await this.agentPool.addToolResult(job.agentId, {
866
- toolId: 'image-gen',
867
- status: 'completed',
868
- result: {
869
- jobId: job.jobId,
870
- prompt: job.prompt,
871
- imageUrl,
872
- localPath: result.resolvedOutputPath,
873
- savedToDisk: result.savedToDisk,
874
- isTemporary
875
- },
876
- timestamp: job.completedAt
877
- });
878
-
879
- this.logger?.info('Image result queued for agent processing', {
880
- agentId: job.agentId,
881
- jobId: job.jobId
882
- });
883
- }
884
- } catch (error) {
885
- this.logger?.error('Failed to save image result to conversation history', {
886
- error: error.message,
887
- agentId: job.agentId,
888
- jobId: job.jobId
889
- });
890
- }
891
- }
892
- }
893
-
894
- this.logger?.info(`Image generation completed: ${job.jobId}`, {
895
- outputPath: result.resolvedOutputPath
896
- });
897
-
898
- } catch (error) {
899
- this.logger?.error(`Image generation failed: ${job.jobId}`, error);
900
-
901
- job.status = 'failed';
902
- // Translate opaque backend errors into something actionable. The
903
- // downstream aiService wraps HTTP errors as "HTTP 400: Bad Request - …"
904
- // with the provider text tacked on; agents previously saw that
905
- // verbatim and had to guess whether it was a size mismatch, a
906
- // content-policy trip, or an auth problem. Route known signals
907
- // to a structured hint.
908
- job.error = _translateImageError(error, job);
909
- job.completedAt = new Date().toISOString();
910
-
911
- this.completedJobs.set(job.jobId, job);
912
-
913
- // Broadcast error to specific session
914
- if (global.loxiaWebServer && job.sessionId) {
915
- global.loxiaWebServer.broadcastToSession(job.sessionId, {
916
- type: 'imageGenerated',
917
- jobId: job.jobId,
918
- agentId: job.agentId,
919
- prompt: job.prompt,
920
- success: false,
921
- error: error.message,
922
- timestamp: job.completedAt
923
- });
924
-
925
- this.logger?.info('Image generation error broadcast sent', {
926
- jobId: job.jobId,
927
- sessionId: job.sessionId,
928
- error: error.message
929
- });
930
- }
931
-
932
- // Save error message to conversation history
933
- if (this.agentPool && job.agentId) {
934
- try {
935
- const agent = await this.agentPool.getAgent(job.agentId);
936
- if (agent) {
937
- // Create error message for conversation history
938
- const errorMessage = {
939
- id: `img-error-${job.jobId}`,
940
- role: 'system',
941
- content: `❌ Image generation failed: ${error.message}\n\n**Prompt:** ${job.prompt}`,
942
- timestamp: job.completedAt,
943
- type: 'error',
944
- toolId: 'image-gen',
945
- status: 'failed',
946
- jobId: job.jobId
947
- };
948
-
949
- // Add to full conversation
950
- agent.conversations.full.messages.push(errorMessage);
951
- agent.conversations.full.lastUpdated = job.completedAt;
952
-
953
- // Add to current model conversation if exists
954
- if (agent.currentModel && agent.conversations[agent.currentModel]) {
955
- agent.conversations[agent.currentModel].messages.push(errorMessage);
956
- agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
957
- }
958
-
959
- // Update agent activity
960
- agent.lastActivity = job.completedAt;
961
-
962
- // Persist agent state to save conversation history
963
- await this.agentPool.persistAgentState(job.agentId);
964
-
965
- this.logger?.info('Image error saved to conversation history', {
966
- agentId: job.agentId,
967
- jobId: job.jobId,
968
- error: error.message
969
- });
970
-
971
- // Queue tool result so agent "sees" the failure and can handle it
972
- await this.agentPool.addToolResult(job.agentId, {
973
- toolId: 'image-gen',
974
- status: 'failed',
975
- error: error.message,
976
- result: {
977
- jobId: job.jobId,
978
- prompt: job.prompt
979
- },
980
- timestamp: job.completedAt
981
- });
982
-
983
- this.logger?.info('Image error queued for agent processing', {
984
- agentId: job.agentId,
985
- jobId: job.jobId
986
- });
987
- }
988
- } catch (historyError) {
989
- this.logger?.error('Failed to save image error to conversation history', {
990
- error: historyError.message,
991
- agentId: job.agentId,
992
- jobId: job.jobId
993
- });
994
- }
995
- }
996
- }
997
- }
998
-
999
- this.isProcessing = false;
1000
- this.currentJob = null;
1001
- }
1002
-
1003
- /**
1004
- * Generate a single image
1005
- * @private
1006
- */
1007
- async _generateImage(job) {
1008
- // Check if AI service is available
1009
- if (!this.aiService) {
1010
- throw new Error('AI service not available. Image generation requires AI service.');
1011
- }
1012
-
1013
- // Auto-select gpt-image-1.5 for transparency or editing
1014
- if (job.transparency && !job.model) {
1015
- job.model = 'gpt-image-1.5';
1016
- }
1017
- if (job.sourceImage && !job.model) {
1018
- job.model = 'gpt-image-1.5';
1019
- }
1020
-
1021
- // Resolve output path
1022
- const resolvedOutputPath = await this._resolveOutputPath(job);
1023
-
1024
- // Ensure directory exists
1025
- const outputDir = path.dirname(resolvedOutputPath);
1026
- await fs.mkdir(outputDir, { recursive: true });
1027
-
1028
- let aiResult;
1029
-
1030
- // Edit mode: source image provided
1031
- if (job.sourceImage) {
1032
- this.logger?.info(`Editing image with ${job.model}`, {
1033
- sourceImage: job.sourceImage,
1034
- hasMask: !!job.mask
1035
- });
1036
-
1037
- // Resolve source image path relative to project directory
1038
- const projectDir = job.projectDir || process.cwd();
1039
- const resolvedSourcePath = path.isAbsolute(job.sourceImage)
1040
- ? job.sourceImage
1041
- : path.resolve(projectDir, job.sourceImage);
1042
-
1043
- // Read source image and convert to base64
1044
- const imageBuffer = await fs.readFile(resolvedSourcePath);
1045
- const imageBase64 = imageBuffer.toString('base64');
1046
-
1047
- // Read mask if provided
1048
- let maskBase64 = null;
1049
- if (job.mask) {
1050
- const resolvedMaskPath = path.isAbsolute(job.mask)
1051
- ? job.mask
1052
- : path.resolve(projectDir, job.mask);
1053
- const maskBuffer = await fs.readFile(resolvedMaskPath);
1054
- maskBase64 = maskBuffer.toString('base64');
1055
- }
1056
-
1057
- aiResult = await this.aiService.editImage(job.prompt, imageBase64, {
1058
- model: job.model,
1059
- maskBase64,
1060
- sessionId: job.sessionId
1061
- });
1062
- } else {
1063
- // Standard generation mode
1064
- this.logger?.info(`Generating image with ${job.model}`, {
1065
- size: job.size,
1066
- quality: job.quality,
1067
- transparency: job.transparency
1068
- });
1069
-
1070
- const options = {
1071
- model: job.model,
1072
- size: job.size,
1073
- quality: job.quality,
1074
- responseFormat: 'url', // Prefer URL, but Flux/GPT-Image returns b64_json
1075
- sessionId: job.sessionId // CRITICAL: Pass sessionId for API key retrieval
1076
- };
1077
-
1078
- aiResult = await this.aiService.generateImage(job.prompt, options);
1079
- }
1080
-
1081
- // AIService returns: { url, b64_json, model, requestId, revisedPrompt }
1082
- // Flux API returns b64_json (base64 encoded image)
1083
- const imageUrl = aiResult?.url || aiResult?.imageUrl;
1084
- const b64Json = aiResult?.b64_json;
1085
-
1086
- if (!imageUrl && !b64Json) {
1087
- throw new Error('No image data received from AI service (no URL or base64)');
1088
- }
1089
-
1090
- // Try to save image to disk
1091
- let savedToDisk = false;
1092
- let downloadError = null;
1093
- let displayUrl = imageUrl; // URL for web display
1094
-
1095
- try {
1096
- if (b64Json) {
1097
- // Flux/GPT-Image response: Save base64 to disk, transcoding to
1098
- // the resolved target format (default webp). If transcoding
1099
- // fails, the helper falls back to writing original bytes — the
1100
- // call doesn't fail, but a warning is logged via the logger.
1101
- this.logger?.info(`Saving base64 image to disk: ${resolvedOutputPath}`);
1102
- const rawBuffer = Buffer.from(b64Json, 'base64');
1103
- const transcode = await transcodeIfNeeded(rawBuffer, job._targetFormat || DEFAULT_FORMAT, {
1104
- quality: 85, logger: this.logger,
1105
- });
1106
- await fs.writeFile(resolvedOutputPath, transcode.buffer);
1107
- if (transcode.transcoded) {
1108
- this.logger?.info(`Image transcoded ${transcode.inputFormat} → ${transcode.outputFormat}`, {
1109
- outputPath: resolvedOutputPath,
1110
- inputBytes: rawBuffer.length,
1111
- outputBytes: transcode.buffer.length,
1112
- });
1113
- }
1114
-
1115
- // Verify the file was actually written
1116
- const stat = await fs.stat(resolvedOutputPath);
1117
- if (stat.size > 0) {
1118
- savedToDisk = true;
1119
- this.logger?.info(`Image saved successfully: ${resolvedOutputPath} (${stat.size} bytes)`);
1120
- } else {
1121
- this.logger?.warn(`Image file is empty after write: ${resolvedOutputPath}`);
1122
- }
1123
-
1124
- // For web display, we'll use our local server endpoint (set below)
1125
- displayUrl = null; // Will be converted to web URL later
1126
- } else if (imageUrl) {
1127
- // URL response: Download from URL, then transcode to target format.
1128
- this.logger?.info(`Downloading image from URL: ${imageUrl.substring(0, 50)}...`);
1129
- await this._downloadImage(imageUrl, resolvedOutputPath, job._targetFormat || DEFAULT_FORMAT);
1130
-
1131
- // Verify the file was actually written
1132
- const stat = await fs.stat(resolvedOutputPath);
1133
- if (stat.size > 0) {
1134
- savedToDisk = true;
1135
- this.logger?.info(`Image downloaded successfully: ${resolvedOutputPath} (${stat.size} bytes)`);
1136
- } else {
1137
- this.logger?.warn(`Downloaded image file is empty: ${resolvedOutputPath}`);
1138
- }
1139
- displayUrl = imageUrl;
1140
- }
1141
-
1142
- // Schedule cleanup if temp file
1143
- if (savedToDisk && !job.saveToProject) {
1144
- this._scheduleCleanup(resolvedOutputPath, job.jobId);
1145
- }
1146
- } catch (error) {
1147
- // Save failed, but we might still have a temporary URL
1148
- downloadError = error.message;
1149
- this.logger?.error(`Failed to save image to disk at ${resolvedOutputPath}: ${error.message}`);
1150
-
1151
- if (!imageUrl) {
1152
- // No URL fallback for Flux - this is a real failure
1153
- throw new Error(`Failed to save image: ${error.message}`);
1154
- }
1155
- }
1156
-
1157
- return {
1158
- jobId: job.jobId,
1159
- prompt: job.prompt,
1160
- outputPath: job.outputPath,
1161
- resolvedOutputPath: savedToDisk ? resolvedOutputPath : null,
1162
- temporaryUrl: displayUrl, // AI-generated URL (valid for ~1 hour) or null for Flux
1163
- savedToDisk,
1164
- downloadError,
1165
- success: true, // Image was generated successfully
1166
- model: aiResult.model || job.model,
1167
- size: job.size,
1168
- usage: aiResult.usage,
1169
- isBase64Response: !!b64Json // Flag to indicate Flux response
1170
- };
1171
- }
1172
-
1173
- /**
1174
- * Resolve output path (temp or project directory)
1175
- * @private
1176
- */
1177
- async _resolveOutputPath(job) {
1178
- // Resolve target format ONCE here so the auto-generated filename
1179
- // uses the right extension. Caller should also use job._targetFormat
1180
- // (cached on the job) when transcoding bytes.
1181
- const { format: targetFormat } = resolveTargetFormat({
1182
- outputPath: job.outputPath,
1183
- outputType: job.outputType,
1184
- });
1185
- job._targetFormat = targetFormat;
1186
- const ext = extensionFor(targetFormat);
1187
-
1188
- if (job.saveToProject) {
1189
- // Save to project directory
1190
- const projectDir = job.projectDir || process.cwd();
1191
-
1192
- let outputPath = job.outputPath;
1193
- if (!outputPath) {
1194
- // Auto-generate filename — DEFAULT format is webp now (was png).
1195
- // Agents that need PNG must pass an explicit outputPath with
1196
- // .png extension OR outputType:'png'.
1197
- const timestamp = Date.now();
1198
- outputPath = `images/generated-${timestamp}.${ext}`;
1199
- } else {
1200
- // Append format extension when the agent supplied a name without one
1201
- outputPath = ensureExtension(outputPath, targetFormat);
1202
- }
1203
-
1204
- const resolvedPath = path.isAbsolute(outputPath)
1205
- ? path.normalize(outputPath)
1206
- : path.normalize(path.join(projectDir, outputPath));
1207
-
1208
- // Security: Check for path traversal
1209
- if (!resolvedPath.startsWith(path.normalize(projectDir))) {
1210
- throw new Error('Path traversal detected');
1211
- }
1212
-
1213
- // Check directory access if provided
1214
- if (job.directoryAccess) {
1215
- // Simple check - file must be within allowed directories
1216
- // Full implementation would use DirectoryAccessManager
1217
- const relativePath = path.relative(projectDir, resolvedPath);
1218
- if (relativePath.startsWith('..')) {
1219
- throw new Error('Access denied: path outside project directory');
1220
- }
1221
- }
1222
-
1223
- return resolvedPath;
1224
- } else {
1225
- // Save to temp directory
1226
- await fs.mkdir(this.tempDir, { recursive: true });
1227
-
1228
- // Temp dir path — same default-extension policy as project save.
1229
- let filename = job.outputPath
1230
- ? path.basename(ensureExtension(job.outputPath, targetFormat))
1231
- : `generated-${job.jobId}.${ext}`;
1232
-
1233
- return path.join(this.tempDir, filename);
1234
- }
1235
- }
1236
-
1237
- /**
1238
- * Download image from URL
1239
- * @private
1240
- */
1241
- async _downloadImage(imageUrl, outputPath, targetFormat = DEFAULT_FORMAT) {
1242
- try {
1243
- const response = await fetch(imageUrl, {
1244
- signal: AbortSignal.timeout(IMAGE_CONFIG.DOWNLOAD_TIMEOUT)
1245
- });
1246
-
1247
- if (!response.ok) {
1248
- throw new Error(`Failed to download image: HTTP ${response.status}`);
1249
- }
1250
-
1251
- const buffer = Buffer.from(await response.arrayBuffer());
1252
- // Transcode to target format if needed (defensive: fallback writes
1253
- // original bytes if sharp can't load — see imageFormat.js).
1254
- const transcode = await transcodeIfNeeded(buffer, targetFormat, {
1255
- quality: 85, logger: this.logger,
1256
- });
1257
- await fs.writeFile(outputPath, transcode.buffer);
1258
- if (transcode.transcoded) {
1259
- this.logger?.info(`Downloaded image transcoded ${transcode.inputFormat} → ${transcode.outputFormat}`, {
1260
- outputPath,
1261
- inputBytes: buffer.length,
1262
- outputBytes: transcode.buffer.length,
1263
- });
1264
- } else {
1265
- this.logger?.info(`Image saved to: ${outputPath}`);
1266
- }
1267
-
1268
- } catch (error) {
1269
- if (error.name === 'TimeoutError') {
1270
- throw new Error('Image download timeout');
1271
- } else if (error.name === 'TypeError') {
1272
- throw new Error(`Network error: ${error.message}`);
1273
- } else {
1274
- throw new Error(`Download failed: ${error.message}`);
1275
- }
1276
- }
1277
- }
1278
-
1279
- /**
1280
- * Schedule cleanup of temp file
1281
- * @private
1282
- */
1283
- _scheduleCleanup(filePath, jobId) {
1284
- const timer = setTimeout(async () => {
1285
- try {
1286
- await fs.unlink(filePath);
1287
- this.logger?.debug(`Cleaned up temp image: ${filePath}`);
1288
- this.cleanupTimers.delete(jobId);
1289
- } catch (error) {
1290
- // File might already be deleted, ignore
1291
- }
1292
- }, IMAGE_CONFIG.TEMP_CLEANUP_MS);
1293
-
1294
- this.cleanupTimers.set(jobId, timer);
1295
- }
1296
-
1297
- /**
1298
- * Convert local file path to web-accessible URL
1299
- * @private
1300
- */
1301
- _convertToWebUrl(localPath, sessionId) {
1302
- // Extract just the filename from the path
1303
- const filename = path.basename(localPath);
1304
-
1305
- // Construct web URL using the image serving endpoint
1306
- // Assumes web server runs on port 8080 (can be made configurable)
1307
- const port = global.loxiaWebServer?.port || 8080;
1308
- let host = global.loxiaWebServer?.host || 'localhost';
1309
-
1310
- // Convert 0.0.0.0 (server binding address) to localhost (browser-accessible)
1311
- // Browsers cannot connect to 0.0.0.0, even though servers can bind to it
1312
- if (host === '0.0.0.0') {
1313
- host = 'localhost';
1314
- }
1315
-
1316
- return `http://${host}:${port}/api/images/${sessionId}/${filename}`;
1317
- }
1318
-
1319
- /**
1320
- * Estimate wait time based on queue
1321
- * @private
1322
- */
1323
- _estimateWaitTime() {
1324
- const avgGenerationTime = 30; // seconds
1325
- const queuePosition = this.queue.length;
1326
-
1327
- if (queuePosition === 0) {
1328
- return '~30 seconds';
1329
- }
1330
-
1331
- const estimatedSeconds = queuePosition * avgGenerationTime;
1332
- const minutes = Math.floor(estimatedSeconds / 60);
1333
- const seconds = estimatedSeconds % 60;
1334
-
1335
- if (minutes > 0) {
1336
- return `~${minutes}m ${seconds}s`;
1337
- }
1338
- return `~${seconds}s`;
1339
- }
1340
-
1341
- /**
1342
- * Generate unique job ID
1343
- * @private
1344
- */
1345
- _generateJobId() {
1346
- return `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1347
- }
1348
-
1349
- /**
1350
- * Get job status
1351
- * @param {string} jobId - Job ID
1352
- * @returns {Object} Job status
1353
- */
1354
- getJobStatus(jobId) {
1355
- // Check completed jobs
1356
- if (this.completedJobs.has(jobId)) {
1357
- return this.completedJobs.get(jobId);
1358
- }
1359
-
1360
- // Check current job
1361
- if (this.currentJob && this.currentJob.jobId === jobId) {
1362
- return this.currentJob;
1363
- }
1364
-
1365
- // Check queue
1366
- const queuedJob = this.queue.find(job => job.jobId === jobId);
1367
- if (queuedJob) {
1368
- return queuedJob;
1369
- }
1370
-
1371
- return {
1372
- jobId,
1373
- status: 'not_found'
1374
- };
1375
- }
1376
-
1377
- /**
1378
- * Cleanup on shutdown
1379
- */
1380
- async cleanup() {
1381
- this.logger?.info('Shutting down ImageTool');
1382
-
1383
- // Clear all cleanup timers
1384
- for (const timer of this.cleanupTimers.values()) {
1385
- clearTimeout(timer);
1386
- }
1387
- this.cleanupTimers.clear();
1388
-
1389
- // Mark queued jobs as cancelled
1390
- for (const job of this.queue) {
1391
- job.status = 'cancelled';
1392
- }
1393
- this.queue = [];
1394
- }
1395
- }
1396
-
1397
- export default ImageTool;
1
+ /**
2
+ * @file tools/imageTool.js
3
+ * @description Tool for generating images using AI models (resolved dynamically from backend)
4
+ */
5
+
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import { promises as fs } from 'fs';
9
+ import { BaseTool } from './baseTool.js';
10
+ import { getGalleryService } from '../services/galleryService.js';
11
+ import {
12
+ resolveTargetFormat,
13
+ transcodeIfNeeded,
14
+ extensionFor,
15
+ ensureExtension,
16
+ DEFAULT_FORMAT,
17
+ } from './imageFormat.js';
18
+
19
+ /**
20
+ * Configuration constants for image generation
21
+ */
22
+ const IMAGE_CONFIG = {
23
+ DEFAULT_MODEL: null, // Resolved dynamically from modelsService via aiService
24
+ DEFAULT_SIZE: '1024x1024',
25
+ DEFAULT_QUALITY: 'standard',
26
+ // Flux-family size rules (used when the effective model matches FLUX_MODELS).
27
+ SIZE_MIN: 256,
28
+ SIZE_MAX: 1440,
29
+ SIZE_INCREMENT: 32, // Must be multiples of 32
30
+
31
+ /**
32
+ * Per-model size rules. Two kinds of rule:
33
+ * { kind: 'enum', values: ['1024x1024', ...] } — exact match required
34
+ * { kind: 'range', min, max, increment } — W×H in grid
35
+ * Key matching is substring, case-insensitive (so 'gpt-image-1.5' matches
36
+ * 'gpt-image-1.5-preview', etc.). The first matching entry wins; anything
37
+ * else falls back to `default`.
38
+ *
39
+ * This table is the SINGLE SOURCE OF TRUTH for what the tool accepts
40
+ * client-side. Previously the validator was hardcoded to Flux rules and
41
+ * actively rejected valid gpt-image-1.5 sizes (1024x1536, 1536x1024, auto)
42
+ * while the tool's own docstring advertised them. See _validateParameters.
43
+ */
44
+ MODEL_SIZES: {
45
+ 'gpt-image-1.5': { kind: 'enum', values: ['1024x1024', '1024x1536', '1536x1024', 'auto'] },
46
+ 'gpt-image-1': { kind: 'enum', values: ['1024x1024', '1024x1536', '1536x1024', 'auto'] },
47
+ 'flux': { kind: 'range', min: 256, max: 1440, increment: 32 },
48
+ 'default': { kind: 'range', min: 256, max: 1440, increment: 32 },
49
+ },
50
+
51
+ /**
52
+ * Size presets, model-aware. Each entry is a shape key; the resolver
53
+ * picks the variant for the effective model. If the model isn't listed,
54
+ * the `default` variant is used (Flux-shaped).
55
+ *
56
+ * Agents were previously confused because `portrait` → 1024x1440 always,
57
+ * even when the tool silently auto-switched to gpt-image-1.5 (where that
58
+ * size is invalid). Now `portrait` resolves to 1024x1536 for gpt-image.
59
+ */
60
+ SIZE_PRESETS: {
61
+ 'square': { default: '1024x1024', 'gpt-image-1.5': '1024x1024' },
62
+ 'square_hd': { default: '1440x1440', 'gpt-image-1.5': '1024x1024' },
63
+ 'portrait': { default: '1024x1440', 'gpt-image-1.5': '1024x1536' },
64
+ 'landscape': { default: '1440x1024', 'gpt-image-1.5': '1536x1024' },
65
+ 'portrait_4_3': { default: '768x1024', 'gpt-image-1.5': '1024x1536' },
66
+ 'landscape_4_3': { default: '1024x768', 'gpt-image-1.5': '1536x1024' },
67
+ 'small': { default: '512x512', 'gpt-image-1.5': '1024x1024' },
68
+ },
69
+
70
+ VALID_FORMATS: ['png', 'jpg', 'jpeg', 'webp'],
71
+ MAX_CONCURRENT: 3,
72
+ QUEUE_LIMIT: 10,
73
+ TEMP_CLEANUP_MS: 3600000, // 1 hour
74
+ MAX_PROMPT_LENGTH: 4000,
75
+ DOWNLOAD_TIMEOUT: 60000 // 60 seconds
76
+ };
77
+
78
+ /**
79
+ * Resolve the effective model given the caller's inputs.
80
+ *
81
+ * The tool auto-switches to `gpt-image-1.5` when the caller asks for
82
+ * transparency or supplies a sourceImage (only gpt-image-1.5 supports
83
+ * those features). Previously this logic lived inside the async worker,
84
+ * AFTER _validateParameters ran — so validation applied Flux rules even
85
+ * when the real model would end up being gpt-image-1.5 and vice versa.
86
+ * Hoisted here so validators, error messages, and preset resolution all
87
+ * see the same truth.
88
+ *
89
+ * @param {object} params - imageParams with { model, transparency, sourceImage }
90
+ * @returns {string} — the effective model id (lowercase)
91
+ */
92
+ export function resolveEffectiveImageModel(params = {}) {
93
+ const explicit = (params.model || '').toLowerCase();
94
+ if (explicit && explicit !== 'auto') return explicit;
95
+ if (params.transparency === true) return 'gpt-image-1.5';
96
+ if (params.sourceImage) return 'gpt-image-1.5';
97
+ // Null → defer to downstream (aiService resolves from catalog). We
98
+ // still return a sentinel so validators can use `default` rules.
99
+ return '';
100
+ }
101
+
102
+ /**
103
+ * Translate an image-generation error into an actionable message for the
104
+ * agent. The backend (`aiService._generateImageOpenAI` / Flux) wraps HTTP
105
+ * responses as `"HTTP 400: Bad Request - <provider text>"` and throws.
106
+ * That passes through to the agent as raw stack text — agents don't know
107
+ * whether they tripped a size rule, a moderation rule, or something else.
108
+ *
109
+ * This helper parses the incoming `Error` for the common signatures and
110
+ * produces a message that includes (a) the effective model, (b) what
111
+ * class of failure it was, and (c) a concrete next-action.
112
+ *
113
+ * @param {Error} error - Thrown from aiService / downstream
114
+ * @param {object} job - The queued job (provides model + size + prompt)
115
+ * @returns {string}
116
+ */
117
+ export function _translateImageError(error, job = {}) {
118
+ const raw = (error && error.message) || String(error || 'unknown error');
119
+ const effectiveModel = resolveEffectiveImageModel(job) || job.model || 'auto';
120
+ const size = job.size || IMAGE_CONFIG.DEFAULT_SIZE;
121
+ const lower = raw.toLowerCase();
122
+
123
+ // Content moderation family — providers use various phrasings.
124
+ if (/moderat|content[_ -]policy|safety|flagged|unsafe/.test(lower)) {
125
+ return (
126
+ `Image generation rejected by content moderation (model=${effectiveModel}). ` +
127
+ `The prompt was flagged. Rephrase rather than retrying verbatim: soften explicit ` +
128
+ `adjectives (e.g. drop "blood", "gore", "bones"), describe style instead of violence, ` +
129
+ `and avoid real-person likenesses. Original provider message: ${raw}`
130
+ );
131
+ }
132
+
133
+ // Size-shaped rejections from the provider (leaks through when our
134
+ // client-side validator missed, or when the catalogue changes).
135
+ if (/size|dimension|unsupported|out of range|must be one of|invalid image size/.test(lower)) {
136
+ const { rule } = _getModelSizeRule(effectiveModel);
137
+ const valid = rule.kind === 'enum'
138
+ ? `Valid sizes for ${effectiveModel}: ${rule.values.join(', ')}.`
139
+ : `Valid for ${effectiveModel}: WIDTHxHEIGHT in ${rule.min}-${rule.max}, multiples of ${rule.increment}.`;
140
+ return (
141
+ `Image generation rejected: size "${size}" not accepted by ${effectiveModel}. ${valid} ` +
142
+ `Original provider message: ${raw}`
143
+ );
144
+ }
145
+
146
+ // Auth / quota.
147
+ if (/unauthorized|forbidden|quota|rate.?limit|429|401|403/.test(lower)) {
148
+ return (
149
+ `Image generation failed for ${effectiveModel}: authentication or quota problem. ` +
150
+ `Retrying with the same inputs will likely fail again — check API keys / billing. ` +
151
+ `Original: ${raw}`
152
+ );
153
+ }
154
+
155
+ // Fall-through: still tell the agent which model ran.
156
+ return `Image generation failed (model=${effectiveModel}, size=${size}): ${raw}`;
157
+ }
158
+
159
+ /**
160
+ * Look up the size rule for an effective model id. Substring match.
161
+ * Always returns a rule (falls back to `default`).
162
+ */
163
+ export function _getModelSizeRule(effectiveModel) {
164
+ const key = String(effectiveModel || '').toLowerCase();
165
+ for (const [needle, rule] of Object.entries(IMAGE_CONFIG.MODEL_SIZES)) {
166
+ if (needle === 'default') continue;
167
+ if (key.includes(needle)) return { modelKey: needle, rule };
168
+ }
169
+ return { modelKey: 'default', rule: IMAGE_CONFIG.MODEL_SIZES.default };
170
+ }
171
+
172
+ /**
173
+ * ImageTool - Generate images using AI models
174
+ * Supports queueing, async processing, and both temp/project directory storage
175
+ */
176
+ export class ImageTool extends BaseTool {
177
+ constructor(config = {}, logger = null) {
178
+ super(config, logger);
179
+
180
+ // Override tool ID
181
+ this.id = 'image-gen';
182
+
183
+ // Job queue and tracking
184
+ this.queue = [];
185
+ this.currentJob = null;
186
+ this.completedJobs = new Map();
187
+ this.isProcessing = false;
188
+
189
+ // AIService will be injected later
190
+ this.aiService = null;
191
+
192
+ // AgentPool will be injected later (for saving to conversation history)
193
+ this.agentPool = null;
194
+
195
+ // Temp directory for images
196
+ this.tempDir = path.join(os.tmpdir(), 'loxia-images');
197
+
198
+ // Cleanup timers
199
+ this.cleanupTimers = new Map();
200
+ }
201
+
202
+ /**
203
+ * Set AI service for image generation
204
+ * @param {AIService} aiService - AI service instance
205
+ */
206
+ setAIService(aiService) {
207
+ this.aiService = aiService;
208
+ this.logger?.info('AI Service set for ImageTool');
209
+ }
210
+
211
+ /**
212
+ * Set Agent Pool for saving results to conversation history
213
+ * @param {AgentPool} agentPool - AgentPool instance
214
+ */
215
+ setAgentPool(agentPool) {
216
+ this.agentPool = agentPool;
217
+ this.logger?.info('AgentPool set for ImageTool');
218
+ }
219
+
220
+ /**
221
+ * Get tool description for agent system prompt
222
+ * @returns {string} Formatted tool description
223
+ */
224
+ getDescription() {
225
+ return `Tool: Image Generator - Generate and edit images using AI models
226
+
227
+ **Purpose:** Generate images from text descriptions, edit existing images, and create images with transparency. Images are saved to files and displayed in chat.
228
+
229
+ **CRITICAL: Automatic Execution**
230
+ - ANY \`\`\`json block with "toolId": "image-gen" will be EXECUTED IMMEDIATELY
231
+ - Just output the command when you want to generate or edit an image
232
+ - If generation fails, output a NEW command with corrections
233
+
234
+ **USAGE — Generate (default WebP):**
235
+ \`\`\`json
236
+ {
237
+ "toolId": "image-gen",
238
+ "parameters": {
239
+ "prompt": "Detailed description of the image",
240
+ "outputPath": "images/filename.webp",
241
+ "size": "1024x1024"
242
+ }
243
+ }
244
+ \`\`\`
245
+
246
+ **USAGE — Force PNG output (when you specifically need lossless / older-tool compat):**
247
+ \`\`\`json
248
+ {
249
+ "toolId": "image-gen",
250
+ "parameters": {
251
+ "prompt": "Pixel-art icon set on white",
252
+ "outputPath": "images/icons.png"
253
+ }
254
+ }
255
+ \`\`\`
256
+ Or, equivalently, with the explicit field:
257
+ \`\`\`json
258
+ {
259
+ "toolId": "image-gen",
260
+ "parameters": { "prompt": "Pixel-art icon set", "outputType": "png" }
261
+ }
262
+ \`\`\`
263
+
264
+ **USAGE — Generate with Transparency (WebP supports alpha):**
265
+ \`\`\`json
266
+ {
267
+ "toolId": "image-gen",
268
+ "parameters": {
269
+ "prompt": "A cute fox mascot on a transparent background",
270
+ "model": "gpt-image-1.5",
271
+ "transparency": true,
272
+ "outputPath": "images/fox-mascot.webp"
273
+ }
274
+ }
275
+ \`\`\`
276
+
277
+ **USAGE — Edit Existing Image:**
278
+ \`\`\`json
279
+ {
280
+ "toolId": "image-gen",
281
+ "parameters": {
282
+ "prompt": "Remove the background and make it transparent",
283
+ "sourceImage": "images/photo.png",
284
+ "model": "gpt-image-1.5",
285
+ "outputPath": "images/photo-nobg.webp"
286
+ }
287
+ }
288
+ \`\`\`
289
+ (The \`sourceImage\` can be any common format — PNG, JPEG, WebP, etc. The OUTPUT format is whatever you specify on \`outputPath\` / \`outputType\`, defaulting to WebP.)
290
+
291
+ **Parameters:**
292
+ - **prompt** (required): Description of image to generate OR editing instruction
293
+ - **outputPath** (optional): Path to save image (permanent). Omit for temp file.
294
+ The file extension determines output format (.webp / .png / .jpg / .jpeg).
295
+ - **outputType** (optional): Explicit format request when outputPath has no extension
296
+ or is omitted: 'webp' | 'png' | 'jpg' | 'jpeg'. Default: 'webp'.
297
+ An outputPath extension always wins over outputType if both are present.
298
+ - **size** (optional): WIDTHxHEIGHT or preset name. DIFFERENT MODELS ACCEPT DIFFERENT SIZES — read the rules below. Default: 1024x1024.
299
+ - **quality** (optional): "standard" or "hd" (default: standard)
300
+ - **model** (optional): "gpt-image-1.5" or a flux variant. Default: auto. Auto-switches to gpt-image-1.5 if transparency=true OR sourceImage is set.
301
+ - **sourceImage** (optional): Path to source image for editing (forces gpt-image-1.5)
302
+ - **mask** (optional): Path to mask image for editing (white=edit area, black=keep area)
303
+ - **transparency** (optional): true to generate with transparent background (forces gpt-image-1.5)
304
+
305
+ **Output format (NEW default: WebP):**
306
+ The tool transcodes provider bytes (typically PNG) into WebP by default —
307
+ smaller files, same visual quality. Force PNG with either an explicit
308
+ .png extension on outputPath OR \`outputType: "png"\`. Pick PNG when you
309
+ need lossless graphics or animated GIFs would otherwise be needed; WebP
310
+ is the better default for photo-style outputs.
311
+
312
+ ⚠ SIZE RULES — read before picking a size:
313
+
314
+ (A) gpt-image-1.5 (auto-selected on transparency=true or sourceImage, or set explicitly):
315
+ size MUST be one of: 1024x1024, 1024x1536 (portrait), 1536x1024 (landscape), or "auto".
316
+ Any other value is rejected; the error lists the valid options.
317
+ Presets remap automatically — e.g. "portrait" → 1024x1536 on this model.
318
+
319
+ (B) Flux models (the default when transparency is not required):
320
+ size must be WIDTHxHEIGHT, both in 256-1440, both multiples of 32 (e.g. 1024x768, 1280x960).
321
+ "auto" is NOT valid for Flux.
322
+
323
+ Presets (shape names — resolved per the chosen model):
324
+ square, square_hd, portrait, landscape, portrait_4_3, landscape_4_3, small
325
+
326
+ Content moderation:
327
+ - Prompts with graphic violence, gore, explicit sexual content, or real-person likenesses are commonly rejected by the provider as "Bad request". If you hit one of those, REPHRASE — don't retry verbatim. Soften explicit adjectives (e.g. "dark fantasy queen with crimson rubies" works; "blood tears, bone crown, dripping" typically doesn't).
328
+
329
+ **Notes:**
330
+ - Images take 15-30 seconds to generate
331
+ - Be descriptive in prompts for better results
332
+ - Use gpt-image-1.5 when you need transparent backgrounds, image editing, or image-to-image transformations
333
+ - Max ${IMAGE_CONFIG.QUEUE_LIMIT} images in queue`;
334
+ }
335
+
336
+ /**
337
+ * Parse image generation parameters
338
+ * @param {string|Object} content - Raw content or parsed object
339
+ * @returns {Object} Parsed parameters
340
+ */
341
+ parseParameters(content) {
342
+ // Handle JSON format
343
+ if (typeof content === 'object' && content !== null) {
344
+ return this._parseJSONParams(content);
345
+ }
346
+
347
+ // Handle string format
348
+ if (typeof content === 'string') {
349
+ const trimmed = content.trim();
350
+
351
+ // Try to parse as JSON first
352
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
353
+ try {
354
+ const parsed = JSON.parse(trimmed);
355
+ return this._parseJSONParams(parsed);
356
+ } catch {
357
+ // Not valid JSON, fall through to XML parsing
358
+ }
359
+ }
360
+
361
+ // Parse as XML
362
+ return this._parseXMLParams(content);
363
+ }
364
+
365
+ throw new Error('Invalid parameter format');
366
+ }
367
+
368
+ /**
369
+ * Parse JSON parameters
370
+ * @private
371
+ */
372
+ _parseJSONParams(obj) {
373
+ // Handle parameters wrapper (when called via toolId/parameters structure)
374
+ if (obj.parameters) {
375
+ obj = obj.parameters;
376
+ }
377
+
378
+ // Check for batch mode
379
+ if (obj.batch && Array.isArray(obj.batch)) {
380
+ return {
381
+ batch: true,
382
+ images: obj.batch.map(img => this._parseImageParams(img))
383
+ };
384
+ }
385
+
386
+ return {
387
+ batch: false,
388
+ images: [this._parseImageParams(obj)]
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Parse XML parameters
394
+ * @private
395
+ */
396
+ _parseXMLParams(content) {
397
+ const params = { batch: false, images: [] };
398
+
399
+ // Check for batch mode
400
+ const batchMatch = /<batch>([\s\S]*?)<\/batch>/i.exec(content);
401
+
402
+ if (batchMatch) {
403
+ params.batch = true;
404
+ const batchContent = batchMatch[1];
405
+
406
+ // Extract individual <image> blocks
407
+ const imageRegex = /<image>([\s\S]*?)<\/image>/gi;
408
+ let match;
409
+
410
+ while ((match = imageRegex.exec(batchContent)) !== null) {
411
+ params.images.push(this._parseXMLImage(match[1]));
412
+ }
413
+ } else {
414
+ // Single image mode
415
+ params.images.push(this._parseXMLImage(content));
416
+ }
417
+
418
+ if (params.images.length === 0) {
419
+ throw new Error('No valid image parameters found');
420
+ }
421
+
422
+ return params;
423
+ }
424
+
425
+ /**
426
+ * Parse single image parameters from object
427
+ * @private
428
+ */
429
+ _parseImageParams(obj) {
430
+ const outputPath = obj.outputPath || obj['output-path'] || null;
431
+ // Optional explicit format request. Accepts 'webp' (default if no
432
+ // outputPath ext), 'png', 'jpg' / 'jpeg'. The outputPath extension
433
+ // wins if both are present (handled in resolveTargetFormat).
434
+ const outputType = obj.outputType || obj['output-type'] || obj.format || null;
435
+
436
+ return {
437
+ prompt: obj.prompt || '',
438
+ outputPath: outputPath,
439
+ outputType: outputType,
440
+ saveToProject: outputPath !== null, // If path specified, save to project
441
+ model: obj.model || IMAGE_CONFIG.DEFAULT_MODEL,
442
+ size: obj.size || IMAGE_CONFIG.DEFAULT_SIZE,
443
+ quality: obj.quality || IMAGE_CONFIG.DEFAULT_QUALITY,
444
+ sourceImage: obj.sourceImage || null, // Edit mode: path to source image
445
+ mask: obj.mask || null, // Edit mode: path to mask image
446
+ transparency: obj.transparency || false // Generate with transparent background
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Parse single image parameters from XML string
452
+ * @private
453
+ */
454
+ _parseXMLImage(xmlContent) {
455
+ const extractTag = (tag) => {
456
+ const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i');
457
+ const match = regex.exec(xmlContent);
458
+ return match ? match[1].trim() : null;
459
+ };
460
+
461
+ const outputPath = extractTag('output-path') || null;
462
+ const outputType = extractTag('output-type') || extractTag('format') || null;
463
+
464
+ return {
465
+ prompt: extractTag('prompt') || '',
466
+ outputPath: outputPath,
467
+ outputType: outputType,
468
+ saveToProject: outputPath !== null, // If path specified, save to project
469
+ model: extractTag('model') || IMAGE_CONFIG.DEFAULT_MODEL,
470
+ size: extractTag('size') || IMAGE_CONFIG.DEFAULT_SIZE,
471
+ quality: extractTag('quality') || IMAGE_CONFIG.DEFAULT_QUALITY,
472
+ sourceImage: extractTag('source-image') || null,
473
+ mask: extractTag('mask') || null,
474
+ transparency: extractTag('transparency') === 'true'
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Execute image generation
480
+ * @param {Object|string} params - Parsed parameters object OR raw XML/JSON string
481
+ * @param {Object} context - Execution context
482
+ * @returns {Promise<Object>} Execution result
483
+ */
484
+ async execute(params, context = {}) {
485
+ try {
486
+ const { agentId, projectDir, directoryAccess, sessionId } = context;
487
+
488
+ // Auto-detect and parse inputs (from TagParser or direct call)
489
+ // parseParameters() normalizes input to { batch: bool, images: [...] } format
490
+ if (typeof params === 'string') {
491
+ this.logger?.info('ImageTool: Auto-parsing string parameters');
492
+ params = this.parseParameters(params);
493
+ } else if (typeof params === 'object' && params !== null && !params.images) {
494
+ // Object params without 'images' array - needs parsing to normalize structure
495
+ this.logger?.info('ImageTool: Normalizing object parameters');
496
+ params = this.parseParameters(params);
497
+ }
498
+
499
+ // Validate parameters
500
+ this._validateParameters(params);
501
+
502
+ // Queue images
503
+ const jobIds = [];
504
+
505
+ for (const imageParams of params.images) {
506
+ // Create job
507
+ const jobId = this._generateJobId();
508
+
509
+ const job = {
510
+ jobId,
511
+ agentId,
512
+ sessionId,
513
+ prompt: imageParams.prompt,
514
+ outputPath: imageParams.outputPath,
515
+ // Explicit format request — used by the save path to decide the
516
+ // target format. Default behavior (no outputPath, no outputType):
517
+ // WebP. See imageFormat.resolveTargetFormat for precedence.
518
+ outputType: imageParams.outputType || null,
519
+ saveToProject: imageParams.saveToProject,
520
+ model: imageParams.model,
521
+ size: imageParams.size,
522
+ quality: imageParams.quality,
523
+ projectDir: projectDir || process.cwd(),
524
+ directoryAccess,
525
+ // Carry the caller's per-agent image-gen config onto the job
526
+ // so the async processor (which doesn't have the original
527
+ // context) can still honor `saveToGallery` etc.
528
+ toolConfig: context?.toolConfig || null,
529
+ status: 'queued',
530
+ createdAt: new Date().toISOString()
531
+ };
532
+
533
+ // Check queue limit
534
+ if (this.queue.length >= IMAGE_CONFIG.QUEUE_LIMIT) {
535
+ return {
536
+ success: false,
537
+ error: `Queue limit reached (${IMAGE_CONFIG.QUEUE_LIMIT} images). Please wait for current jobs to complete.`,
538
+ queueLength: this.queue.length
539
+ };
540
+ }
541
+
542
+ this.queue.push(job);
543
+ jobIds.push(jobId);
544
+
545
+ this.logger?.info(`Image generation job queued: ${jobId}`, {
546
+ prompt: imageParams.prompt.substring(0, 50) + '...',
547
+ queuePosition: this.queue.length
548
+ });
549
+ }
550
+
551
+ // Start processing if not already running
552
+ if (!this.isProcessing) {
553
+ this._processQueue().catch(err => {
554
+ this.logger?.error('Queue processing error:', err);
555
+ });
556
+ }
557
+
558
+ // Return immediate response
559
+ return {
560
+ success: true,
561
+ jobIds,
562
+ queueLength: this.queue.length,
563
+ message: params.batch
564
+ ? `${jobIds.length} images queued for generation`
565
+ : 'Image queued for generation',
566
+ estimatedWaitTime: this._estimateWaitTime()
567
+ };
568
+
569
+ } catch (error) {
570
+ this.logger?.error('Image generation error:', error);
571
+ return {
572
+ success: false,
573
+ error: error.message
574
+ };
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Validate parameters
580
+ * @private
581
+ */
582
+ _validateParameters(params) {
583
+ if (!params.images || params.images.length === 0) {
584
+ throw new Error('No images specified');
585
+ }
586
+
587
+ for (const img of params.images) {
588
+ if (!img.prompt || img.prompt.trim().length === 0) {
589
+ throw new Error('Image prompt is required');
590
+ }
591
+
592
+ if (img.prompt.length > IMAGE_CONFIG.MAX_PROMPT_LENGTH) {
593
+ throw new Error(`Prompt too long (max ${IMAGE_CONFIG.MAX_PROMPT_LENGTH} characters)`);
594
+ }
595
+
596
+ // Resolve the effective model FIRST so size validation, preset
597
+ // resolution, and error messages all see the model that will
598
+ // actually run. Previously this logic lived downstream in the
599
+ // async worker so Flux rules were applied even when the tool
600
+ // would silently switch to gpt-image-1.5 (transparency=true,
601
+ // sourceImage set, or explicit model) — and vice versa.
602
+ const effectiveModel = resolveEffectiveImageModel(img);
603
+ const { modelKey: ruleKey, rule } = _getModelSizeRule(effectiveModel);
604
+
605
+ if (img.size) {
606
+ // Step 1: expand preset names. Presets are model-aware — e.g.
607
+ // `portrait` is 1024x1440 on Flux but 1024x1536 on gpt-image-1.5
608
+ // (the latter only accepts specific enum values). Previously the
609
+ // preset was resolved Flux-shaped and then rejected downstream.
610
+ const presetEntry = IMAGE_CONFIG.SIZE_PRESETS[img.size];
611
+ if (presetEntry) {
612
+ img.size = presetEntry[ruleKey] || presetEntry.default;
613
+ }
614
+
615
+ // Step 2: per-model rule check.
616
+ if (rule.kind === 'enum') {
617
+ // gpt-image-1.5 et al — exact enum.
618
+ if (!rule.values.includes(img.size)) {
619
+ const modelLabel = effectiveModel || 'gpt-image-1.5';
620
+ const autoHint = img.transparency
621
+ ? ' (auto-selected because transparency=true)'
622
+ : img.sourceImage
623
+ ? ' (auto-selected because sourceImage was provided)'
624
+ : '';
625
+ throw new Error(
626
+ `Image size "${img.size}" is not supported by ${modelLabel}${autoHint}. ` +
627
+ `Valid sizes: ${rule.values.join(', ')}. ` +
628
+ `For portrait orientation use "1024x1536"; for landscape use "1536x1024".`
629
+ );
630
+ }
631
+ } else {
632
+ // Range rule (Flux and default). `auto` is an enum-only value
633
+ // that makes no sense for a range model — reject with a hint.
634
+ if (img.size === 'auto') {
635
+ throw new Error(
636
+ `Image size "auto" is only valid for gpt-image-1.5. ` +
637
+ `For the current model (${effectiveModel || 'flux'}) pick a WIDTHxHEIGHT ` +
638
+ `between ${rule.min} and ${rule.max} in ${rule.increment}-pixel steps ` +
639
+ `(e.g. 1024x1024).`
640
+ );
641
+ }
642
+ const sizeMatch = img.size.match(/^(\d+)x(\d+)$/);
643
+ if (!sizeMatch) {
644
+ throw new Error(
645
+ `Invalid size format: "${img.size}". ` +
646
+ `Use WIDTHxHEIGHT (e.g. 1024x768) or one of these presets: ` +
647
+ `${Object.keys(IMAGE_CONFIG.SIZE_PRESETS).join(', ')}.`
648
+ );
649
+ }
650
+ const width = parseInt(sizeMatch[1], 10);
651
+ const height = parseInt(sizeMatch[2], 10);
652
+ const { min, max, increment } = rule;
653
+ const modelLabel = effectiveModel || 'flux';
654
+ if (width < min || width > max) {
655
+ throw new Error(
656
+ `Width ${width} out of range for ${modelLabel}. Must be ${min}-${max}px ` +
657
+ `(multiple of ${increment}).`
658
+ );
659
+ }
660
+ if (height < min || height > max) {
661
+ throw new Error(
662
+ `Height ${height} out of range for ${modelLabel}. Must be ${min}-${max}px ` +
663
+ `(multiple of ${increment}).`
664
+ );
665
+ }
666
+ if (width % increment !== 0) {
667
+ throw new Error(
668
+ `Width ${width} must be a multiple of ${increment} for ${modelLabel}. ` +
669
+ `Nearest valid: ${Math.round(width / increment) * increment}.`
670
+ );
671
+ }
672
+ if (height % increment !== 0) {
673
+ throw new Error(
674
+ `Height ${height} must be a multiple of ${increment} for ${modelLabel}. ` +
675
+ `Nearest valid: ${Math.round(height / increment) * increment}.`
676
+ );
677
+ }
678
+ }
679
+ }
680
+
681
+ if (img.outputPath) {
682
+ const ext = path.extname(img.outputPath).toLowerCase().replace('.', '');
683
+ if (ext && !IMAGE_CONFIG.VALID_FORMATS.includes(ext)) {
684
+ throw new Error(`Invalid format: ${ext}. Valid formats: ${IMAGE_CONFIG.VALID_FORMATS.join(', ')}`);
685
+ }
686
+ }
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Process the image generation queue
692
+ * @private
693
+ */
694
+ async _processQueue() {
695
+ if (this.isProcessing) {
696
+ return;
697
+ }
698
+
699
+ this.isProcessing = true;
700
+
701
+ while (this.queue.length > 0) {
702
+ const job = this.queue.shift();
703
+ this.currentJob = job;
704
+
705
+ this.logger?.info(`Processing image generation job: ${job.jobId}`);
706
+
707
+ try {
708
+ job.status = 'processing';
709
+
710
+ // Generate the image
711
+ const result = await this._generateImage(job);
712
+
713
+ job.status = 'completed';
714
+ job.result = result;
715
+ job.completedAt = new Date().toISOString();
716
+
717
+ // Store completed job
718
+ this.completedJobs.set(job.jobId, job);
719
+
720
+ // Broadcast result via WebSocket
721
+ if (global.loxiaWebServer && job.sessionId) {
722
+ // Determine which URL to use: local saved file or temporary AI URL
723
+ let imageUrl;
724
+ let isTemporary = false;
725
+
726
+ this.logger?.info('Image generation result', {
727
+ jobId: job.jobId,
728
+ savedToDisk: result.savedToDisk,
729
+ resolvedOutputPath: result.resolvedOutputPath,
730
+ temporaryUrl: result.temporaryUrl?.substring(0, 80),
731
+ downloadError: result.downloadError,
732
+ isBase64Response: result.isBase64Response
733
+ });
734
+
735
+ if (result.savedToDisk && result.resolvedOutputPath) {
736
+ // Image was saved successfully - use our server endpoint
737
+ imageUrl = this._convertToWebUrl(result.resolvedOutputPath, job.sessionId);
738
+ this.logger?.info('Using local server URL for image', { imageUrl });
739
+
740
+ // Durable gallery copy (non-blocking, non-fatal).
741
+ // Gated by per-agent `toolConfig.image-gen.saveToGallery`.
742
+ // Default is ON — users who want to keep disk usage down can
743
+ // opt out via the image-gen configurator in the agent modal.
744
+ const saveToGallery = job.toolConfig?.saveToGallery !== false;
745
+ if (saveToGallery) {
746
+ try {
747
+ let agentName = null;
748
+ if (this.agentPool && job.agentId) {
749
+ try {
750
+ const a = await this.agentPool.getAgent(job.agentId);
751
+ agentName = a?.name || null;
752
+ } catch { /* non-fatal */ }
753
+ }
754
+ await getGalleryService(this.logger).saveImage({
755
+ sourcePath: result.resolvedOutputPath,
756
+ metadata: {
757
+ prompt: job.prompt,
758
+ model: job.model,
759
+ size: job.size,
760
+ quality: job.quality,
761
+ agentId: job.agentId,
762
+ agentName,
763
+ sessionId: job.sessionId,
764
+ jobId: job.jobId,
765
+ createdAt: job.completedAt || job.createdAt,
766
+ },
767
+ });
768
+ } catch (galErr) {
769
+ this.logger?.warn?.('Gallery save failed (non-fatal)', {
770
+ jobId: job.jobId,
771
+ error: galErr.message,
772
+ });
773
+ }
774
+ }
775
+ } else if (result.temporaryUrl) {
776
+ // Download failed - use temporary AI-generated URL (expires in ~1 hour)
777
+ imageUrl = result.temporaryUrl;
778
+ isTemporary = true;
779
+ this.logger?.warn('Using temporary AI URL for image (local save failed)', {
780
+ imageUrl: imageUrl.substring(0, 80),
781
+ downloadError: result.downloadError
782
+ });
783
+ } else {
784
+ this.logger?.error('No image URL available - neither local save nor temporary URL', {
785
+ jobId: job.jobId,
786
+ savedToDisk: result.savedToDisk,
787
+ hasTemporaryUrl: !!result.temporaryUrl
788
+ });
789
+ }
790
+
791
+ global.loxiaWebServer.broadcastToSession(job.sessionId, {
792
+ type: 'imageGenerated',
793
+ agentId: job.agentId,
794
+ jobId: job.jobId,
795
+ imageUrl,
796
+ localPath: result.resolvedOutputPath,
797
+ prompt: job.prompt,
798
+ success: true,
799
+ isTemporary, // Indicates if URL will expire
800
+ savedToDisk: result.savedToDisk,
801
+ downloadError: result.downloadError, // Include error if save failed
802
+ timestamp: job.completedAt
803
+ });
804
+
805
+ this.logger?.info('Image generation broadcast sent', {
806
+ jobId: job.jobId,
807
+ imageUrl,
808
+ localPath: result.resolvedOutputPath,
809
+ savedToDisk: result.savedToDisk,
810
+ isTemporary
811
+ });
812
+
813
+ // Save image result to conversation history for persistence
814
+ if (this.agentPool && job.agentId) {
815
+ try {
816
+ const agent = await this.agentPool.getAgent(job.agentId);
817
+ if (agent) {
818
+ // Build message content with warnings if applicable
819
+ let content = `Image generated: ${job.prompt}`;
820
+
821
+ if (isTemporary) {
822
+ content += '\n\n⚠️ **Warning:** Image is using a temporary URL (expires in ~1 hour). Failed to save to disk.';
823
+ if (result.downloadError) {
824
+ content += `\n**Error:** ${result.downloadError}`;
825
+ }
826
+ }
827
+
828
+ // Create image message for conversation history
829
+ const imageMessage = {
830
+ id: `img-result-${job.jobId}`,
831
+ role: 'assistant',
832
+ content,
833
+ timestamp: job.completedAt,
834
+ imageUrl, // CRITICAL: Include imageUrl so it persists across page refreshes
835
+ type: 'image-result',
836
+ toolId: 'image-gen',
837
+ status: 'completed',
838
+ isTemporary: isTemporary || false,
839
+ savedToDisk: result.savedToDisk !== false
840
+ };
841
+
842
+ // Add to full conversation
843
+ agent.conversations.full.messages.push(imageMessage);
844
+ agent.conversations.full.lastUpdated = job.completedAt;
845
+
846
+ // Add to current model conversation if exists
847
+ if (agent.currentModel && agent.conversations[agent.currentModel]) {
848
+ agent.conversations[agent.currentModel].messages.push(imageMessage);
849
+ agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
850
+ }
851
+
852
+ // Update agent activity
853
+ agent.lastActivity = job.completedAt;
854
+
855
+ // Persist agent state to save conversation history
856
+ await this.agentPool.persistAgentState(job.agentId);
857
+
858
+ this.logger?.info('Image result saved to conversation history', {
859
+ agentId: job.agentId,
860
+ jobId: job.jobId,
861
+ messageId: imageMessage.id
862
+ });
863
+
864
+ // Queue tool result so agent "sees" the completion and can continue
865
+ await this.agentPool.addToolResult(job.agentId, {
866
+ toolId: 'image-gen',
867
+ status: 'completed',
868
+ result: {
869
+ jobId: job.jobId,
870
+ prompt: job.prompt,
871
+ imageUrl,
872
+ localPath: result.resolvedOutputPath,
873
+ savedToDisk: result.savedToDisk,
874
+ isTemporary
875
+ },
876
+ timestamp: job.completedAt
877
+ });
878
+
879
+ this.logger?.info('Image result queued for agent processing', {
880
+ agentId: job.agentId,
881
+ jobId: job.jobId
882
+ });
883
+ }
884
+ } catch (error) {
885
+ this.logger?.error('Failed to save image result to conversation history', {
886
+ error: error.message,
887
+ agentId: job.agentId,
888
+ jobId: job.jobId
889
+ });
890
+ }
891
+ }
892
+ }
893
+
894
+ this.logger?.info(`Image generation completed: ${job.jobId}`, {
895
+ outputPath: result.resolvedOutputPath
896
+ });
897
+
898
+ } catch (error) {
899
+ this.logger?.error(`Image generation failed: ${job.jobId}`, error);
900
+
901
+ job.status = 'failed';
902
+ // Translate opaque backend errors into something actionable. The
903
+ // downstream aiService wraps HTTP errors as "HTTP 400: Bad Request - …"
904
+ // with the provider text tacked on; agents previously saw that
905
+ // verbatim and had to guess whether it was a size mismatch, a
906
+ // content-policy trip, or an auth problem. Route known signals
907
+ // to a structured hint.
908
+ job.error = _translateImageError(error, job);
909
+ job.completedAt = new Date().toISOString();
910
+
911
+ this.completedJobs.set(job.jobId, job);
912
+
913
+ // Broadcast error to specific session
914
+ if (global.loxiaWebServer && job.sessionId) {
915
+ global.loxiaWebServer.broadcastToSession(job.sessionId, {
916
+ type: 'imageGenerated',
917
+ jobId: job.jobId,
918
+ agentId: job.agentId,
919
+ prompt: job.prompt,
920
+ success: false,
921
+ error: error.message,
922
+ timestamp: job.completedAt
923
+ });
924
+
925
+ this.logger?.info('Image generation error broadcast sent', {
926
+ jobId: job.jobId,
927
+ sessionId: job.sessionId,
928
+ error: error.message
929
+ });
930
+ }
931
+
932
+ // Save error message to conversation history
933
+ if (this.agentPool && job.agentId) {
934
+ try {
935
+ const agent = await this.agentPool.getAgent(job.agentId);
936
+ if (agent) {
937
+ // Create error message for conversation history
938
+ const errorMessage = {
939
+ id: `img-error-${job.jobId}`,
940
+ role: 'system',
941
+ content: `❌ Image generation failed: ${error.message}\n\n**Prompt:** ${job.prompt}`,
942
+ timestamp: job.completedAt,
943
+ type: 'error',
944
+ toolId: 'image-gen',
945
+ status: 'failed',
946
+ jobId: job.jobId
947
+ };
948
+
949
+ // Add to full conversation
950
+ agent.conversations.full.messages.push(errorMessage);
951
+ agent.conversations.full.lastUpdated = job.completedAt;
952
+
953
+ // Add to current model conversation if exists
954
+ if (agent.currentModel && agent.conversations[agent.currentModel]) {
955
+ agent.conversations[agent.currentModel].messages.push(errorMessage);
956
+ agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
957
+ }
958
+
959
+ // Update agent activity
960
+ agent.lastActivity = job.completedAt;
961
+
962
+ // Persist agent state to save conversation history
963
+ await this.agentPool.persistAgentState(job.agentId);
964
+
965
+ this.logger?.info('Image error saved to conversation history', {
966
+ agentId: job.agentId,
967
+ jobId: job.jobId,
968
+ error: error.message
969
+ });
970
+
971
+ // Queue tool result so agent "sees" the failure and can handle it
972
+ await this.agentPool.addToolResult(job.agentId, {
973
+ toolId: 'image-gen',
974
+ status: 'failed',
975
+ error: error.message,
976
+ result: {
977
+ jobId: job.jobId,
978
+ prompt: job.prompt
979
+ },
980
+ timestamp: job.completedAt
981
+ });
982
+
983
+ this.logger?.info('Image error queued for agent processing', {
984
+ agentId: job.agentId,
985
+ jobId: job.jobId
986
+ });
987
+ }
988
+ } catch (historyError) {
989
+ this.logger?.error('Failed to save image error to conversation history', {
990
+ error: historyError.message,
991
+ agentId: job.agentId,
992
+ jobId: job.jobId
993
+ });
994
+ }
995
+ }
996
+ }
997
+ }
998
+
999
+ this.isProcessing = false;
1000
+ this.currentJob = null;
1001
+ }
1002
+
1003
+ /**
1004
+ * Generate a single image
1005
+ * @private
1006
+ */
1007
+ async _generateImage(job) {
1008
+ // Check if AI service is available
1009
+ if (!this.aiService) {
1010
+ throw new Error('AI service not available. Image generation requires AI service.');
1011
+ }
1012
+
1013
+ // Auto-select gpt-image-1.5 for transparency or editing
1014
+ if (job.transparency && !job.model) {
1015
+ job.model = 'gpt-image-1.5';
1016
+ }
1017
+ if (job.sourceImage && !job.model) {
1018
+ job.model = 'gpt-image-1.5';
1019
+ }
1020
+
1021
+ // Resolve output path
1022
+ const resolvedOutputPath = await this._resolveOutputPath(job);
1023
+
1024
+ // Ensure directory exists
1025
+ const outputDir = path.dirname(resolvedOutputPath);
1026
+ await fs.mkdir(outputDir, { recursive: true });
1027
+
1028
+ let aiResult;
1029
+
1030
+ // Edit mode: source image provided
1031
+ if (job.sourceImage) {
1032
+ this.logger?.info(`Editing image with ${job.model}`, {
1033
+ sourceImage: job.sourceImage,
1034
+ hasMask: !!job.mask
1035
+ });
1036
+
1037
+ // Resolve source image path relative to project directory
1038
+ const projectDir = job.projectDir || process.cwd();
1039
+ const resolvedSourcePath = path.isAbsolute(job.sourceImage)
1040
+ ? job.sourceImage
1041
+ : path.resolve(projectDir, job.sourceImage);
1042
+
1043
+ // Read source image and convert to base64
1044
+ const imageBuffer = await fs.readFile(resolvedSourcePath);
1045
+ const imageBase64 = imageBuffer.toString('base64');
1046
+
1047
+ // Read mask if provided
1048
+ let maskBase64 = null;
1049
+ if (job.mask) {
1050
+ const resolvedMaskPath = path.isAbsolute(job.mask)
1051
+ ? job.mask
1052
+ : path.resolve(projectDir, job.mask);
1053
+ const maskBuffer = await fs.readFile(resolvedMaskPath);
1054
+ maskBase64 = maskBuffer.toString('base64');
1055
+ }
1056
+
1057
+ aiResult = await this.aiService.editImage(job.prompt, imageBase64, {
1058
+ model: job.model,
1059
+ maskBase64,
1060
+ sessionId: job.sessionId
1061
+ });
1062
+ } else {
1063
+ // Standard generation mode
1064
+ this.logger?.info(`Generating image with ${job.model}`, {
1065
+ size: job.size,
1066
+ quality: job.quality,
1067
+ transparency: job.transparency
1068
+ });
1069
+
1070
+ const options = {
1071
+ model: job.model,
1072
+ size: job.size,
1073
+ quality: job.quality,
1074
+ responseFormat: 'url', // Prefer URL, but Flux/GPT-Image returns b64_json
1075
+ sessionId: job.sessionId // CRITICAL: Pass sessionId for API key retrieval
1076
+ };
1077
+
1078
+ aiResult = await this.aiService.generateImage(job.prompt, options);
1079
+ }
1080
+
1081
+ // AIService returns: { url, b64_json, model, requestId, revisedPrompt }
1082
+ // Flux API returns b64_json (base64 encoded image)
1083
+ const imageUrl = aiResult?.url || aiResult?.imageUrl;
1084
+ const b64Json = aiResult?.b64_json;
1085
+
1086
+ if (!imageUrl && !b64Json) {
1087
+ throw new Error('No image data received from AI service (no URL or base64)');
1088
+ }
1089
+
1090
+ // Try to save image to disk
1091
+ let savedToDisk = false;
1092
+ let downloadError = null;
1093
+ let displayUrl = imageUrl; // URL for web display
1094
+
1095
+ try {
1096
+ if (b64Json) {
1097
+ // Flux/GPT-Image response: Save base64 to disk, transcoding to
1098
+ // the resolved target format (default webp). If transcoding
1099
+ // fails, the helper falls back to writing original bytes — the
1100
+ // call doesn't fail, but a warning is logged via the logger.
1101
+ this.logger?.info(`Saving base64 image to disk: ${resolvedOutputPath}`);
1102
+ const rawBuffer = Buffer.from(b64Json, 'base64');
1103
+ const transcode = await transcodeIfNeeded(rawBuffer, job._targetFormat || DEFAULT_FORMAT, {
1104
+ quality: 85, logger: this.logger,
1105
+ });
1106
+ await fs.writeFile(resolvedOutputPath, transcode.buffer);
1107
+ if (transcode.transcoded) {
1108
+ this.logger?.info(`Image transcoded ${transcode.inputFormat} → ${transcode.outputFormat}`, {
1109
+ outputPath: resolvedOutputPath,
1110
+ inputBytes: rawBuffer.length,
1111
+ outputBytes: transcode.buffer.length,
1112
+ });
1113
+ }
1114
+
1115
+ // Verify the file was actually written
1116
+ const stat = await fs.stat(resolvedOutputPath);
1117
+ if (stat.size > 0) {
1118
+ savedToDisk = true;
1119
+ this.logger?.info(`Image saved successfully: ${resolvedOutputPath} (${stat.size} bytes)`);
1120
+ } else {
1121
+ this.logger?.warn(`Image file is empty after write: ${resolvedOutputPath}`);
1122
+ }
1123
+
1124
+ // For web display, we'll use our local server endpoint (set below)
1125
+ displayUrl = null; // Will be converted to web URL later
1126
+ } else if (imageUrl) {
1127
+ // URL response: Download from URL, then transcode to target format.
1128
+ this.logger?.info(`Downloading image from URL: ${imageUrl.substring(0, 50)}...`);
1129
+ await this._downloadImage(imageUrl, resolvedOutputPath, job._targetFormat || DEFAULT_FORMAT);
1130
+
1131
+ // Verify the file was actually written
1132
+ const stat = await fs.stat(resolvedOutputPath);
1133
+ if (stat.size > 0) {
1134
+ savedToDisk = true;
1135
+ this.logger?.info(`Image downloaded successfully: ${resolvedOutputPath} (${stat.size} bytes)`);
1136
+ } else {
1137
+ this.logger?.warn(`Downloaded image file is empty: ${resolvedOutputPath}`);
1138
+ }
1139
+ displayUrl = imageUrl;
1140
+ }
1141
+
1142
+ // Schedule cleanup if temp file
1143
+ if (savedToDisk && !job.saveToProject) {
1144
+ this._scheduleCleanup(resolvedOutputPath, job.jobId);
1145
+ }
1146
+ } catch (error) {
1147
+ // Save failed, but we might still have a temporary URL
1148
+ downloadError = error.message;
1149
+ this.logger?.error(`Failed to save image to disk at ${resolvedOutputPath}: ${error.message}`);
1150
+
1151
+ if (!imageUrl) {
1152
+ // No URL fallback for Flux - this is a real failure
1153
+ throw new Error(`Failed to save image: ${error.message}`, { cause: error });
1154
+ }
1155
+ }
1156
+
1157
+ return {
1158
+ jobId: job.jobId,
1159
+ prompt: job.prompt,
1160
+ outputPath: job.outputPath,
1161
+ resolvedOutputPath: savedToDisk ? resolvedOutputPath : null,
1162
+ temporaryUrl: displayUrl, // AI-generated URL (valid for ~1 hour) or null for Flux
1163
+ savedToDisk,
1164
+ downloadError,
1165
+ success: true, // Image was generated successfully
1166
+ model: aiResult.model || job.model,
1167
+ size: job.size,
1168
+ usage: aiResult.usage,
1169
+ isBase64Response: !!b64Json // Flag to indicate Flux response
1170
+ };
1171
+ }
1172
+
1173
+ /**
1174
+ * Resolve output path (temp or project directory)
1175
+ * @private
1176
+ */
1177
+ async _resolveOutputPath(job) {
1178
+ // Resolve target format ONCE here so the auto-generated filename
1179
+ // uses the right extension. Caller should also use job._targetFormat
1180
+ // (cached on the job) when transcoding bytes.
1181
+ const { format: targetFormat } = resolveTargetFormat({
1182
+ outputPath: job.outputPath,
1183
+ outputType: job.outputType,
1184
+ });
1185
+ job._targetFormat = targetFormat;
1186
+ const ext = extensionFor(targetFormat);
1187
+
1188
+ if (job.saveToProject) {
1189
+ // Save to project directory
1190
+ const projectDir = job.projectDir || process.cwd();
1191
+
1192
+ let outputPath = job.outputPath;
1193
+ if (!outputPath) {
1194
+ // Auto-generate filename — DEFAULT format is webp now (was png).
1195
+ // Agents that need PNG must pass an explicit outputPath with
1196
+ // .png extension OR outputType:'png'.
1197
+ const timestamp = Date.now();
1198
+ outputPath = `images/generated-${timestamp}.${ext}`;
1199
+ } else {
1200
+ // Append format extension when the agent supplied a name without one
1201
+ outputPath = ensureExtension(outputPath, targetFormat);
1202
+ }
1203
+
1204
+ const resolvedPath = path.isAbsolute(outputPath)
1205
+ ? path.normalize(outputPath)
1206
+ : path.normalize(path.join(projectDir, outputPath));
1207
+
1208
+ // Security: Check for path traversal
1209
+ if (!resolvedPath.startsWith(path.normalize(projectDir))) {
1210
+ throw new Error('Path traversal detected');
1211
+ }
1212
+
1213
+ // Check directory access if provided
1214
+ if (job.directoryAccess) {
1215
+ // Simple check - file must be within allowed directories
1216
+ // Full implementation would use DirectoryAccessManager
1217
+ const relativePath = path.relative(projectDir, resolvedPath);
1218
+ if (relativePath.startsWith('..')) {
1219
+ throw new Error('Access denied: path outside project directory');
1220
+ }
1221
+ }
1222
+
1223
+ return resolvedPath;
1224
+ } else {
1225
+ // Save to temp directory
1226
+ await fs.mkdir(this.tempDir, { recursive: true });
1227
+
1228
+ // Temp dir path — same default-extension policy as project save.
1229
+ let filename = job.outputPath
1230
+ ? path.basename(ensureExtension(job.outputPath, targetFormat))
1231
+ : `generated-${job.jobId}.${ext}`;
1232
+
1233
+ return path.join(this.tempDir, filename);
1234
+ }
1235
+ }
1236
+
1237
+ /**
1238
+ * Download image from URL
1239
+ * @private
1240
+ */
1241
+ async _downloadImage(imageUrl, outputPath, targetFormat = DEFAULT_FORMAT) {
1242
+ try {
1243
+ const response = await fetch(imageUrl, {
1244
+ signal: AbortSignal.timeout(IMAGE_CONFIG.DOWNLOAD_TIMEOUT)
1245
+ });
1246
+
1247
+ if (!response.ok) {
1248
+ throw new Error(`Failed to download image: HTTP ${response.status}`);
1249
+ }
1250
+
1251
+ const buffer = Buffer.from(await response.arrayBuffer());
1252
+ // Transcode to target format if needed (defensive: fallback writes
1253
+ // original bytes if sharp can't load — see imageFormat.js).
1254
+ const transcode = await transcodeIfNeeded(buffer, targetFormat, {
1255
+ quality: 85, logger: this.logger,
1256
+ });
1257
+ await fs.writeFile(outputPath, transcode.buffer);
1258
+ if (transcode.transcoded) {
1259
+ this.logger?.info(`Downloaded image transcoded ${transcode.inputFormat} → ${transcode.outputFormat}`, {
1260
+ outputPath,
1261
+ inputBytes: buffer.length,
1262
+ outputBytes: transcode.buffer.length,
1263
+ });
1264
+ } else {
1265
+ this.logger?.info(`Image saved to: ${outputPath}`);
1266
+ }
1267
+
1268
+ } catch (error) {
1269
+ if (error.name === 'TimeoutError') {
1270
+ throw new Error('Image download timeout', { cause: error });
1271
+ } else if (error.name === 'TypeError') {
1272
+ throw new Error(`Network error: ${error.message}`, { cause: error });
1273
+ } else {
1274
+ throw new Error(`Download failed: ${error.message}`, { cause: error });
1275
+ }
1276
+ }
1277
+ }
1278
+
1279
+ /**
1280
+ * Schedule cleanup of temp file
1281
+ * @private
1282
+ */
1283
+ _scheduleCleanup(filePath, jobId) {
1284
+ const timer = setTimeout(async () => {
1285
+ try {
1286
+ await fs.unlink(filePath);
1287
+ this.logger?.debug(`Cleaned up temp image: ${filePath}`);
1288
+ this.cleanupTimers.delete(jobId);
1289
+ } catch {
1290
+ // File might already be deleted, ignore
1291
+ }
1292
+ }, IMAGE_CONFIG.TEMP_CLEANUP_MS);
1293
+
1294
+ this.cleanupTimers.set(jobId, timer);
1295
+ }
1296
+
1297
+ /**
1298
+ * Convert local file path to web-accessible URL
1299
+ * @private
1300
+ */
1301
+ _convertToWebUrl(localPath, sessionId) {
1302
+ // Extract just the filename from the path
1303
+ const filename = path.basename(localPath);
1304
+
1305
+ // Construct web URL using the image serving endpoint
1306
+ // Assumes web server runs on port 8080 (can be made configurable)
1307
+ const port = global.loxiaWebServer?.port || 8080;
1308
+ let host = global.loxiaWebServer?.host || 'localhost';
1309
+
1310
+ // Convert 0.0.0.0 (server binding address) to localhost (browser-accessible)
1311
+ // Browsers cannot connect to 0.0.0.0, even though servers can bind to it
1312
+ if (host === '0.0.0.0') {
1313
+ host = 'localhost';
1314
+ }
1315
+
1316
+ return `http://${host}:${port}/api/images/${sessionId}/${filename}`;
1317
+ }
1318
+
1319
+ /**
1320
+ * Estimate wait time based on queue
1321
+ * @private
1322
+ */
1323
+ _estimateWaitTime() {
1324
+ const avgGenerationTime = 30; // seconds
1325
+ const queuePosition = this.queue.length;
1326
+
1327
+ if (queuePosition === 0) {
1328
+ return '~30 seconds';
1329
+ }
1330
+
1331
+ const estimatedSeconds = queuePosition * avgGenerationTime;
1332
+ const minutes = Math.floor(estimatedSeconds / 60);
1333
+ const seconds = estimatedSeconds % 60;
1334
+
1335
+ if (minutes > 0) {
1336
+ return `~${minutes}m ${seconds}s`;
1337
+ }
1338
+ return `~${seconds}s`;
1339
+ }
1340
+
1341
+ /**
1342
+ * Generate unique job ID
1343
+ * @private
1344
+ */
1345
+ _generateJobId() {
1346
+ return `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1347
+ }
1348
+
1349
+ /**
1350
+ * Get job status
1351
+ * @param {string} jobId - Job ID
1352
+ * @returns {Object} Job status
1353
+ */
1354
+ getJobStatus(jobId) {
1355
+ // Check completed jobs
1356
+ if (this.completedJobs.has(jobId)) {
1357
+ return this.completedJobs.get(jobId);
1358
+ }
1359
+
1360
+ // Check current job
1361
+ if (this.currentJob && this.currentJob.jobId === jobId) {
1362
+ return this.currentJob;
1363
+ }
1364
+
1365
+ // Check queue
1366
+ const queuedJob = this.queue.find(job => job.jobId === jobId);
1367
+ if (queuedJob) {
1368
+ return queuedJob;
1369
+ }
1370
+
1371
+ return {
1372
+ jobId,
1373
+ status: 'not_found'
1374
+ };
1375
+ }
1376
+
1377
+ /**
1378
+ * Cleanup on shutdown
1379
+ */
1380
+ async cleanup() {
1381
+ this.logger?.info('Shutting down ImageTool');
1382
+
1383
+ // Clear all cleanup timers
1384
+ for (const timer of this.cleanupTimers.values()) {
1385
+ clearTimeout(timer);
1386
+ }
1387
+ this.cleanupTimers.clear();
1388
+
1389
+ // Mark queued jobs as cancelled
1390
+ for (const job of this.queue) {
1391
+ job.status = 'cancelled';
1392
+ }
1393
+ this.queue = [];
1394
+ }
1395
+ }
1396
+
1397
+ export default ImageTool;