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,1303 +1,1303 @@
1
- /**
2
- * @file tools/videoTool.js
3
- * @description Tool for generating videos 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
-
12
- /**
13
- * Configuration constants for video generation.
14
- *
15
- * Per-model limits (allowed resolutions, allowed durations, whether
16
- * variants are supported) are NOT hardcoded here — they live in the
17
- * model catalog (`video_config` block on each video-generation row) and
18
- * are enforced authoritatively by the backend route. The CLI sends the
19
- * request and surfaces the backend's 400 verbatim if the inputs aren't
20
- * compatible with the chosen model. This keeps the CLI provider-agnostic
21
- * (Sora 1, Sora 2, future video models all just work) and avoids
22
- * silently-stale validation tables here.
23
- *
24
- * The constants below are CLI-side controls only (queue/concurrency/
25
- * polling/temp file lifetime/timeout). No model-specific values.
26
- */
27
- const VIDEO_CONFIG = {
28
- DEFAULT_MODEL: null, // resolved dynamically by aiService
29
- DEFAULT_WIDTH: 1280, // sensible default; any video model in the
30
- DEFAULT_HEIGHT: 720, // catalog with `1280x720` accepts these
31
- DEFAULT_DURATION: 4, // Sora 2's smallest unit; safe across providers
32
- DEFAULT_VARIANTS: 1, // single video per job (Sora 2; Sora 1 supported up to 4)
33
- MAX_PROMPT_LENGTH: 4000,
34
- // Hard CLI safety bounds — let the backend/catalog be authoritative on
35
- // *which specific values* are allowed for the chosen model, but reject
36
- // obviously-bogus inputs locally so the user gets immediate feedback.
37
- ABSOLUTE_MIN_DURATION: 1,
38
- ABSOLUTE_MAX_DURATION: 60,
39
- ABSOLUTE_MAX_VARIANTS: 8,
40
- // Queue + scheduler controls (CLI-side only).
41
- MAX_CONCURRENT: 2,
42
- QUEUE_LIMIT: 5,
43
- POLL_INTERVAL: 5000, // ms between status checks
44
- MAX_POLL_TIME: 600000, // 10 minutes max wait per job
45
- TEMP_CLEANUP_MS: 86400000, // 24 h (Sora 2 expires content after 24 h anyway)
46
- DOWNLOAD_TIMEOUT: 300000 // 5 minutes for video download
47
- };
48
-
49
- /**
50
- * VideoTool - Generate videos using Sora 2 AI model
51
- * Supports async job-based processing with polling
52
- */
53
- export class VideoTool extends BaseTool {
54
- constructor(config = {}, logger = null) {
55
- super(config, logger);
56
-
57
- // Override tool ID
58
- this.id = 'video-gen';
59
-
60
- // Job queue and tracking
61
- this.queue = [];
62
- this.activeJobs = new Map(); // Currently processing jobs (max 2)
63
- this.completedJobs = new Map();
64
- this.isProcessing = false;
65
-
66
- // AIService will be injected later
67
- this.aiService = null;
68
-
69
- // AgentPool will be injected later (for saving to conversation history)
70
- this.agentPool = null;
71
-
72
- // Temp directory for videos
73
- this.tempDir = path.join(os.tmpdir(), 'loxia-videos');
74
-
75
- // Cleanup timers
76
- this.cleanupTimers = new Map();
77
-
78
- // Polling timers
79
- this.pollTimers = new Map();
80
- }
81
-
82
- /**
83
- * Snapshot of the job state for the web-UI `/api/videos/jobs` endpoint.
84
- * Serialises the three job maps into a stable JSON shape that the
85
- * Video Jobs page can render + poll on. Intentionally excludes the
86
- * large `result` blobs for completed jobs to keep the list light —
87
- * consumers that want the full result hit the file-serve URL instead.
88
- *
89
- * @returns {{
90
- * queued: Array<JobSummary>,
91
- * active: Array<JobSummary>,
92
- * completed: Array<JobSummary>,
93
- * limits: { concurrency: number, queue: number },
94
- * }}
95
- */
96
- getJobsSnapshot() {
97
- const summarize = (j) => ({
98
- jobId: j.jobId,
99
- agentId: j.agentId,
100
- sessionId: j.sessionId,
101
- prompt: typeof j.prompt === 'string' ? j.prompt.slice(0, 300) : '',
102
- model: j.model,
103
- width: j.width,
104
- height: j.height,
105
- duration: j.duration,
106
- variants: j.variants,
107
- status: j.status || 'unknown',
108
- soraJobId: j.soraJobId || null,
109
- outputPath: j.outputPath || null,
110
- createdAt: j.createdAt || null,
111
- startedAt: j.startedAt || null,
112
- completedAt: j.completedAt || null,
113
- error: j.error || null,
114
- // Best-effort URL to the final video file if known. The existing
115
- // /api/videos/:sessionId/:filename serve route locates the file.
116
- videoUrl: (j.status === 'completed' && j.outputPath && j.sessionId)
117
- ? `/api/videos/${encodeURIComponent(j.sessionId)}/${encodeURIComponent(
118
- typeof j.outputPath === 'string' ? j.outputPath.split(/[\\/]/).pop() : ''
119
- )}`
120
- : null,
121
- });
122
- return {
123
- queued: Array.from(this.queue).map(summarize),
124
- active: Array.from(this.activeJobs.values()).map(summarize),
125
- completed: Array.from(this.completedJobs.values()).map(summarize),
126
- limits: {
127
- // Was reading the non-existent VIDEO_CONFIG.CONCURRENCY_LIMIT and
128
- // falling through to `activeJobs.size + queue.length`, which is
129
- // 0 when idle and produced a misleading `"concurrency": 0` in the
130
- // /api/videos/jobs snapshot (the real cap is MAX_CONCURRENT=2).
131
- concurrency: VIDEO_CONFIG?.MAX_CONCURRENT ?? null,
132
- queue: VIDEO_CONFIG?.QUEUE_LIMIT ?? null,
133
- },
134
- };
135
- }
136
-
137
- /**
138
- * Cancel a video generation job by id. Removes from queue if still
139
- * pending, marks active jobs for cancellation (best-effort — an
140
- * in-flight Sora API call will still complete, but we stop polling
141
- * and stop persisting the result). Returns { success, state }.
142
- *
143
- * Completed and already-failed jobs are not cancellable.
144
- */
145
- cancelJob(jobId) {
146
- // Queued — just remove from the queue.
147
- const qIdx = this.queue.findIndex(j => j.jobId === jobId);
148
- if (qIdx !== -1) {
149
- const [job] = this.queue.splice(qIdx, 1);
150
- job.status = 'cancelled';
151
- job.completedAt = new Date().toISOString();
152
- this.completedJobs.set(job.jobId, job);
153
- this.logger?.info('Video job cancelled (was queued)', { jobId });
154
- return { success: true, state: 'queued' };
155
- }
156
- // Active — mark + stop polling.
157
- if (this.activeJobs.has(jobId)) {
158
- const job = this.activeJobs.get(jobId);
159
- job.status = 'cancelled';
160
- job.completedAt = new Date().toISOString();
161
- const timer = this.pollTimers?.get(jobId);
162
- if (timer) { clearTimeout(timer); this.pollTimers.delete(jobId); }
163
- this.activeJobs.delete(jobId);
164
- this.completedJobs.set(jobId, job);
165
- this.logger?.info('Video job cancelled (was active)', { jobId });
166
- return { success: true, state: 'active' };
167
- }
168
- // Completed / unknown.
169
- return { success: false, error: 'Job not found or already terminal' };
170
- }
171
-
172
- /**
173
- * Set AI service for video generation
174
- * @param {AIService} aiService - AI service instance
175
- */
176
- setAIService(aiService) {
177
- this.aiService = aiService;
178
- this.logger?.info('AI Service set for VideoTool');
179
- }
180
-
181
- /**
182
- * Set Agent Pool for saving results to conversation history
183
- * @param {AgentPool} agentPool - AgentPool instance
184
- */
185
- setAgentPool(agentPool) {
186
- this.agentPool = agentPool;
187
- this.logger?.info('AgentPool set for VideoTool');
188
- }
189
-
190
- /**
191
- * Get tool description for agent system prompt
192
- * @returns {string} Formatted tool description
193
- */
194
- getDescription() {
195
- return `Tool: Video Generator — generate videos from text using the platform's video model.
196
-
197
- **Purpose:** Generate short videos from text descriptions. Videos are saved to disk and made available in chat. The active video model is resolved automatically from the model catalog (currently Sora 2 on this deployment); model-specific limits like allowed resolutions and durations come from the catalog and are enforced by the backend.
198
-
199
- **CRITICAL: Automatic Execution**
200
- - ANY \`\`\`json block with "toolId": "video-gen" will be EXECUTED IMMEDIATELY.
201
- - Just output the command when you want to generate a video.
202
- - Video generation takes minutes — a job ID is returned immediately and the file lands when the job finishes.
203
-
204
- **USAGE:**
205
- \`\`\`json
206
- {
207
- "toolId": "video-gen",
208
- "parameters": {
209
- "prompt": "Detailed description of the video",
210
- "outputPath": "videos/filename.mp4",
211
- "width": 1280,
212
- "height": 720,
213
- "duration": 4
214
- }
215
- }
216
- \`\`\`
217
-
218
- **Parameters:**
219
- - **prompt** (required): Detailed description of the video.
220
- - **outputPath** (optional): Where to save the .mp4 (permanent). Omit for a temp file.
221
- - **width** / **height** (optional): Resolution. The catalog defines allowed pairs per model; the backend rejects unsupported pairs with a clear 400 listing valid values.
222
- - **duration** (optional): Duration in seconds. The catalog defines allowed values per model.
223
- - **variants** (optional): Number of video variants. Some models (e.g. Sora 2) only return one video per job; the backend will reject \`variants > 1\` with a clear message naming the model's limit.
224
- - **model** (optional): Override the auto-resolved model. Must be a video-generation model in the catalog.
225
-
226
- **Current model limits (Sora 2):**
227
- - Resolutions: \`720x1280\`, \`1280x720\`, \`1024x1792\`, \`1792x1024\` (16:9 / 9:16 only — no square or 480p)
228
- - Durations: \`4\`, \`8\`, or \`12\` seconds (those exact values, no others)
229
- - Variants: 1 per job — request multiple separately if you need more
230
-
231
- These limits live in the catalog row, not in this tool. If the model rotates (e.g. Sora 3) or a new video model is added, the allowed values update automatically and you'll see them in the backend's 400 if you submit unsupported inputs.
232
-
233
- **EXAMPLE:**
234
- User: "create a video of a cat playing"
235
- You output:
236
- \`\`\`json
237
- {
238
- "toolId": "video-gen",
239
- "parameters": {
240
- "prompt": "A fluffy orange tabby cat with white paws crouches low on a sunlit hardwood floor, eyes locked on a red laser dot. The cat pounces forward, slides slightly on the polished wood, then quickly pivots to chase the dot as it darts away. Warm afternoon sunlight streams through sheer curtains, casting soft golden highlights on the cat's fur. Cozy living room with a beige sofa in the background.",
241
- "outputPath": "videos/cat-playing.mp4",
242
- "width": 1280,
243
- "height": 720,
244
- "duration": 4
245
- }
246
- }
247
- \`\`\`
248
-
249
- **Prompt Guidelines (IMPORTANT):**
250
- Sora simulates a physical world — write prompts as narratives, not camera commands. Use the CAST method: describe the **Character** (appearance, clothing, posture), **Action** (broken into beats: "takes three steps, pauses, looks back"), **Setting** (time of day, weather, specific objects), and **Tone/Atmosphere** (lighting quality, color palette, mood). Be specific and sensory — replace "beautiful street" with "rain-slick Tokyo asphalt reflecting neon signs." Keep prompts under 120 words, focus on ONE action per clip, and use simple camera cues only if needed ("wide shot," "close-up"). Anchor lighting explicitly ("warm golden hour sunlight with soft shadows") and name colors (teal, amber, magenta) for palette consistency. For character consistency across clips, repeat the same distinctive details (clothing colors, accessories, features) in each prompt. Avoid real people, copyrighted characters, and sensitive content.
251
-
252
- **Notes:**
253
- - Videos take 1-3 minutes to generate (Sora 2 short clips).
254
- - Max ${VIDEO_CONFIG.QUEUE_LIMIT} videos in queue, max ${VIDEO_CONFIG.MAX_CONCURRENT} concurrent jobs.
255
- - Generated videos expire on the provider after 24 hours; if you need to keep them, set an \`outputPath\` so the local copy persists.`;
256
- }
257
-
258
- /**
259
- * Parse video generation parameters
260
- * @param {string|Object} content - Raw content or parsed object
261
- * @returns {Object} Parsed parameters
262
- */
263
- parseParameters(content) {
264
- // Handle JSON format
265
- if (typeof content === 'object' && content !== null) {
266
- return this._parseJSONParams(content);
267
- }
268
-
269
- // Handle string format
270
- if (typeof content === 'string') {
271
- const trimmed = content.trim();
272
-
273
- // Try to parse as JSON first
274
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
275
- try {
276
- const parsed = JSON.parse(trimmed);
277
- return this._parseJSONParams(parsed);
278
- } catch (err) {
279
- // Not valid JSON, fall through to XML parsing
280
- }
281
- }
282
-
283
- // Parse as XML
284
- return this._parseXMLParams(content);
285
- }
286
-
287
- throw new Error('Invalid parameter format');
288
- }
289
-
290
- /**
291
- * Parse JSON parameters
292
- * @private
293
- */
294
- _parseJSONParams(obj) {
295
- // Handle parameters wrapper (when called via toolId/parameters structure)
296
- if (obj.parameters) {
297
- obj = obj.parameters;
298
- }
299
-
300
- // Check for batch mode
301
- if (obj.batch && Array.isArray(obj.batch)) {
302
- return {
303
- batch: true,
304
- videos: obj.batch.map(vid => this._parseVideoParams(vid))
305
- };
306
- }
307
-
308
- return {
309
- batch: false,
310
- videos: [this._parseVideoParams(obj)]
311
- };
312
- }
313
-
314
- /**
315
- * Parse XML parameters
316
- * @private
317
- */
318
- _parseXMLParams(content) {
319
- const params = { batch: false, videos: [] };
320
-
321
- // Check for batch mode
322
- const batchMatch = /<batch>([\s\S]*?)<\/batch>/i.exec(content);
323
-
324
- if (batchMatch) {
325
- params.batch = true;
326
- const batchContent = batchMatch[1];
327
-
328
- // Extract individual <video> blocks
329
- const videoRegex = /<video>([\s\S]*?)<\/video>/gi;
330
- let match;
331
-
332
- while ((match = videoRegex.exec(batchContent)) !== null) {
333
- params.videos.push(this._parseXMLVideo(match[1]));
334
- }
335
- } else {
336
- // Single video mode
337
- params.videos.push(this._parseXMLVideo(content));
338
- }
339
-
340
- if (params.videos.length === 0) {
341
- throw new Error('No valid video parameters found');
342
- }
343
-
344
- return params;
345
- }
346
-
347
- /**
348
- * Parse single video parameters from object
349
- * @private
350
- */
351
- _parseVideoParams(obj) {
352
- const outputPath = obj.outputPath || obj['output-path'] || null;
353
-
354
- return {
355
- prompt: obj.prompt || '',
356
- outputPath: outputPath,
357
- saveToProject: outputPath !== null,
358
- model: obj.model || VIDEO_CONFIG.DEFAULT_MODEL,
359
- width: parseInt(obj.width) || VIDEO_CONFIG.DEFAULT_WIDTH,
360
- height: parseInt(obj.height) || VIDEO_CONFIG.DEFAULT_HEIGHT,
361
- duration: parseInt(obj.duration) || VIDEO_CONFIG.DEFAULT_DURATION,
362
- variants: parseInt(obj.variants) || VIDEO_CONFIG.DEFAULT_VARIANTS
363
- };
364
- }
365
-
366
- /**
367
- * Parse single video parameters from XML string
368
- * @private
369
- */
370
- _parseXMLVideo(xmlContent) {
371
- const extractTag = (tag) => {
372
- const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i');
373
- const match = regex.exec(xmlContent);
374
- return match ? match[1].trim() : null;
375
- };
376
-
377
- const outputPath = extractTag('output-path') || null;
378
-
379
- return {
380
- prompt: extractTag('prompt') || '',
381
- outputPath: outputPath,
382
- saveToProject: outputPath !== null,
383
- model: extractTag('model') || VIDEO_CONFIG.DEFAULT_MODEL,
384
- width: parseInt(extractTag('width')) || VIDEO_CONFIG.DEFAULT_WIDTH,
385
- height: parseInt(extractTag('height')) || VIDEO_CONFIG.DEFAULT_HEIGHT,
386
- duration: parseInt(extractTag('duration')) || VIDEO_CONFIG.DEFAULT_DURATION,
387
- variants: parseInt(extractTag('variants')) || VIDEO_CONFIG.DEFAULT_VARIANTS
388
- };
389
- }
390
-
391
- /**
392
- * Execute video generation
393
- * @param {Object|string} params - Parsed parameters object OR raw XML/JSON string
394
- * @param {Object} context - Execution context
395
- * @returns {Promise<Object>} Execution result
396
- */
397
- async execute(params, context = {}) {
398
- try {
399
- const { agentId, projectDir, directoryAccess, sessionId } = context;
400
-
401
- // Auto-detect and parse inputs
402
- if (typeof params === 'string') {
403
- this.logger?.info('VideoTool: Auto-parsing string parameters');
404
- params = this.parseParameters(params);
405
- } else if (typeof params === 'object' && params !== null && !params.videos) {
406
- this.logger?.info('VideoTool: Normalizing object parameters');
407
- params = this.parseParameters(params);
408
- }
409
-
410
- // Validate parameters
411
- this._validateParameters(params);
412
-
413
- // Queue videos
414
- const jobIds = [];
415
-
416
- for (const videoParams of params.videos) {
417
- // Create job
418
- const jobId = this._generateJobId();
419
-
420
- const job = {
421
- jobId,
422
- agentId,
423
- sessionId,
424
- prompt: videoParams.prompt,
425
- outputPath: videoParams.outputPath,
426
- saveToProject: videoParams.saveToProject,
427
- model: videoParams.model,
428
- width: videoParams.width,
429
- height: videoParams.height,
430
- duration: videoParams.duration,
431
- variants: videoParams.variants,
432
- projectDir: projectDir || process.cwd(),
433
- directoryAccess,
434
- // Per-agent video-gen config (reaches the async processor
435
- // through the job). Used to gate `saveToGallery` below.
436
- toolConfig: context?.toolConfig || null,
437
- status: 'queued',
438
- soraJobId: null, // Will be set when submitted to Sora
439
- createdAt: new Date().toISOString()
440
- };
441
-
442
- // Check queue limit
443
- if (this.queue.length >= VIDEO_CONFIG.QUEUE_LIMIT) {
444
- return {
445
- success: false,
446
- error: `Queue limit reached (${VIDEO_CONFIG.QUEUE_LIMIT} videos). Please wait for current jobs to complete.`,
447
- queueLength: this.queue.length
448
- };
449
- }
450
-
451
- this.queue.push(job);
452
- jobIds.push(jobId);
453
-
454
- this.logger?.info(`Video generation job queued: ${jobId}`, {
455
- prompt: videoParams.prompt.substring(0, 50) + '...',
456
- queuePosition: this.queue.length
457
- });
458
- }
459
-
460
- // Start processing if not already running
461
- if (!this.isProcessing) {
462
- this._processQueue().catch(err => {
463
- this.logger?.error('Queue processing error:', err);
464
- });
465
- }
466
-
467
- // Return immediate response
468
- return {
469
- success: true,
470
- jobIds,
471
- queueLength: this.queue.length,
472
- activeJobs: this.activeJobs.size,
473
- message: params.batch
474
- ? `${jobIds.length} videos queued for generation`
475
- : 'Video queued for generation',
476
- estimatedWaitTime: this._estimateWaitTime()
477
- };
478
-
479
- } catch (error) {
480
- this.logger?.error('Video generation error:', error);
481
- return {
482
- success: false,
483
- error: error.message
484
- };
485
- }
486
- }
487
-
488
- /**
489
- * Sanity-check inputs before submitting to the backend. Per-model limits
490
- * (which exact resolutions and durations a given video model accepts)
491
- * are NOT enforced here — the catalog declares them and the backend
492
- * route validates against the catalog row. This keeps the CLI
493
- * provider-agnostic; if the inputs aren't compatible with the chosen
494
- * model, the backend's 400 surfaces back through the queue's `error`
495
- * field with a clear message naming the allowed values.
496
- *
497
- * Local checks below cover only "obviously wrong" inputs that no video
498
- * provider would accept — empty prompts, non-positive dimensions, etc.
499
- *
500
- * @private
501
- */
502
- _validateParameters(params) {
503
- if (!params.videos || params.videos.length === 0) {
504
- throw new Error('No videos specified');
505
- }
506
-
507
- for (const vid of params.videos) {
508
- if (!vid.prompt || vid.prompt.trim().length === 0) {
509
- throw new Error('Video prompt is required');
510
- }
511
- if (vid.prompt.length > VIDEO_CONFIG.MAX_PROMPT_LENGTH) {
512
- throw new Error(`Prompt too long (max ${VIDEO_CONFIG.MAX_PROMPT_LENGTH} characters)`);
513
- }
514
-
515
- // Width/height must be positive integers — backend + provider
516
- // decide which exact pairs are valid for the chosen model.
517
- if (vid.width != null && (!Number.isInteger(vid.width) || vid.width <= 0)) {
518
- throw new Error(`Invalid width: ${vid.width}. Must be a positive integer.`);
519
- }
520
- if (vid.height != null && (!Number.isInteger(vid.height) || vid.height <= 0)) {
521
- throw new Error(`Invalid height: ${vid.height}. Must be a positive integer.`);
522
- }
523
-
524
- // Duration: only catch absurd values here; the catalog declares
525
- // the per-model allowed list (e.g. Sora 2: {4, 8, 12} seconds).
526
- if (vid.duration != null) {
527
- if (typeof vid.duration !== 'number' || vid.duration < VIDEO_CONFIG.ABSOLUTE_MIN_DURATION || vid.duration > VIDEO_CONFIG.ABSOLUTE_MAX_DURATION) {
528
- throw new Error(`Invalid duration: ${vid.duration}. Must be a number between ${VIDEO_CONFIG.ABSOLUTE_MIN_DURATION} and ${VIDEO_CONFIG.ABSOLUTE_MAX_DURATION} seconds. The chosen model may support a narrower set — see /llm/models.`);
529
- }
530
- }
531
-
532
- // Variants: same approach — sanity-only. Some models (e.g. Sora 2)
533
- // only return a single video per job; the backend will 400 if the
534
- // model doesn't support multiple variants.
535
- if (vid.variants != null) {
536
- if (!Number.isInteger(vid.variants) || vid.variants < 1 || vid.variants > VIDEO_CONFIG.ABSOLUTE_MAX_VARIANTS) {
537
- throw new Error(`Invalid variants: ${vid.variants}. Must be an integer between 1 and ${VIDEO_CONFIG.ABSOLUTE_MAX_VARIANTS}. The chosen model may only support 1.`);
538
- }
539
- }
540
-
541
- if (vid.outputPath) {
542
- const ext = path.extname(vid.outputPath).toLowerCase();
543
- if (ext && ext !== '.mp4') {
544
- throw new Error(`Invalid format: ${ext}. Only .mp4 is supported`);
545
- }
546
- }
547
- }
548
- }
549
-
550
- /**
551
- * Process the video generation queue
552
- * @private
553
- */
554
- async _processQueue() {
555
- if (this.isProcessing) {
556
- return;
557
- }
558
-
559
- this.isProcessing = true;
560
-
561
- while (this.queue.length > 0 || this.activeJobs.size > 0) {
562
- // Start new jobs if under concurrent limit
563
- while (this.queue.length > 0 && this.activeJobs.size < VIDEO_CONFIG.MAX_CONCURRENT) {
564
- const job = this.queue.shift();
565
- await this._startVideoJob(job);
566
- }
567
-
568
- // Wait a bit before checking again
569
- await new Promise(resolve => setTimeout(resolve, 1000));
570
- }
571
-
572
- this.isProcessing = false;
573
- }
574
-
575
- /**
576
- * Start a video generation job
577
- * @private
578
- */
579
- async _startVideoJob(job) {
580
- this.logger?.info(`Starting video generation job: ${job.jobId}`);
581
-
582
- try {
583
- job.status = 'submitting';
584
-
585
- // Check if AI service is available
586
- if (!this.aiService) {
587
- throw new Error('AI service not available. Video generation requires AI service.');
588
- }
589
-
590
- // Submit to Sora API
591
- const options = {
592
- width: job.width,
593
- height: job.height,
594
- duration: job.duration,
595
- variants: job.variants,
596
- sessionId: job.sessionId
597
- };
598
-
599
- const result = await this.aiService.generateVideo(job.prompt, options);
600
-
601
- // Store Sora job ID
602
- job.soraJobId = result.jobId;
603
- job.status = 'processing';
604
- job.submittedAt = new Date().toISOString();
605
-
606
- // Add to active jobs
607
- this.activeJobs.set(job.jobId, job);
608
-
609
- this.logger?.info(`Video job submitted to Sora: ${job.soraJobId}`, {
610
- localJobId: job.jobId
611
- });
612
-
613
- // Broadcast status update
614
- this._broadcastJobStatus(job, 'processing', 'Video generation started');
615
-
616
- // Start polling for completion
617
- this._pollJobStatus(job);
618
-
619
- } catch (error) {
620
- this.logger?.error(`Failed to start video job: ${job.jobId}`, error);
621
-
622
- job.status = 'failed';
623
- job.error = error.message;
624
- job.completedAt = new Date().toISOString();
625
-
626
- this.completedJobs.set(job.jobId, job);
627
-
628
- // Broadcast error
629
- this._broadcastJobStatus(job, 'failed', error.message);
630
- }
631
- }
632
-
633
- /**
634
- * Poll for job completion
635
- * @private
636
- */
637
- async _pollJobStatus(job) {
638
- const startTime = Date.now();
639
-
640
- const poll = async () => {
641
- try {
642
- // Check if we've exceeded max poll time
643
- if (Date.now() - startTime > VIDEO_CONFIG.MAX_POLL_TIME) {
644
- throw new Error('Video generation timeout - exceeded maximum wait time');
645
- }
646
-
647
- // Get status from Sora - pass sessionId for API key retrieval
648
- const status = await this.aiService.getVideoJobStatus(job.soraJobId, {
649
- sessionId: job.sessionId,
650
- model: job.model
651
- });
652
-
653
- this.logger?.debug(`Video job status: ${status.status}`, {
654
- jobId: job.jobId,
655
- soraJobId: job.soraJobId,
656
- sessionId: job.sessionId
657
- });
658
-
659
- if (status.status === 'succeeded' || status.status === 'completed') {
660
- // Job completed successfully
661
- await this._handleJobComplete(job, status);
662
- } else if (status.status === 'failed' || status.status === 'cancelled') {
663
- // Job failed
664
- throw new Error(status.error || 'Video generation failed');
665
- } else {
666
- // Still processing - poll again
667
- const timer = setTimeout(() => poll(), VIDEO_CONFIG.POLL_INTERVAL);
668
- this.pollTimers.set(job.jobId, timer);
669
- }
670
- } catch (error) {
671
- this.logger?.error(`Video job failed: ${job.jobId}`, error);
672
- await this._handleJobFailed(job, error);
673
- }
674
- };
675
-
676
- // Start polling
677
- poll();
678
- }
679
-
680
- /**
681
- * Handle successful job completion
682
- * @private
683
- */
684
- async _handleJobComplete(job, status) {
685
- this.logger?.info(`Video generation completed: ${job.jobId}`);
686
-
687
- try {
688
- // Clear poll timer
689
- const timer = this.pollTimers.get(job.jobId);
690
- if (timer) {
691
- clearTimeout(timer);
692
- this.pollTimers.delete(job.jobId);
693
- }
694
-
695
- // Remove from active jobs
696
- this.activeJobs.delete(job.jobId);
697
-
698
- // Get generation ID from status - Sora returns generation ID, not direct URL
699
- const generationId = status.generationId || status.generations?.[0]?.id;
700
-
701
- if (!generationId) {
702
- throw new Error('No generation ID received from Sora');
703
- }
704
-
705
- // Construct video download URL through our backend proxy
706
- // The backend will authenticate with Sora and stream the video
707
- const backendUrl = this.aiService?.baseUrl || process.env.LOXIA_BACKEND_URL || 'http://localhost:3001';
708
- const videoUrl = `${backendUrl}/llm/video-content/${generationId}?model=${job.model || ''}`;
709
-
710
- this.logger?.info(`Video content URL: ${videoUrl}`, { generationId });
711
-
712
- // Resolve output path and download
713
- const resolvedOutputPath = await this._resolveOutputPath(job);
714
-
715
- let savedToDisk = false;
716
- let downloadError = null;
717
-
718
- try {
719
- await fs.mkdir(path.dirname(resolvedOutputPath), { recursive: true });
720
- await this._downloadVideo(videoUrl, resolvedOutputPath, job.sessionId);
721
- savedToDisk = true;
722
-
723
- // Schedule cleanup if temp file
724
- if (!job.saveToProject) {
725
- this._scheduleCleanup(resolvedOutputPath, job.jobId);
726
- }
727
- } catch (err) {
728
- downloadError = err.message;
729
- this.logger?.warn(`Failed to save video to disk: ${err.message}`);
730
- }
731
-
732
- job.status = 'completed';
733
- job.result = {
734
- jobId: job.jobId,
735
- soraJobId: job.soraJobId,
736
- prompt: job.prompt,
737
- outputPath: job.outputPath,
738
- resolvedOutputPath: savedToDisk ? resolvedOutputPath : null,
739
- temporaryUrl: videoUrl,
740
- savedToDisk,
741
- downloadError,
742
- success: true,
743
- model: job.model,
744
- width: job.width,
745
- height: job.height,
746
- duration: job.duration,
747
- generations: status.generations || []
748
- };
749
- job.completedAt = new Date().toISOString();
750
-
751
- this.completedJobs.set(job.jobId, job);
752
-
753
- // Durable gallery copy — non-blocking, non-fatal. Skipped when
754
- // the agent has opted out via `toolConfig.video-gen.saveToGallery`.
755
- if (savedToDisk && resolvedOutputPath && job.toolConfig?.saveToGallery !== false) {
756
- try {
757
- let agentName = null;
758
- if (this.agentPool && job.agentId) {
759
- try {
760
- const a = await this.agentPool.getAgent(job.agentId);
761
- agentName = a?.name || null;
762
- } catch { /* non-fatal */ }
763
- }
764
- await getGalleryService(this.logger).saveVideo({
765
- sourcePath: resolvedOutputPath,
766
- metadata: {
767
- prompt: job.prompt,
768
- model: job.model,
769
- width: job.width,
770
- height: job.height,
771
- duration: job.duration,
772
- variants: job.variants,
773
- agentId: job.agentId,
774
- agentName,
775
- sessionId: job.sessionId,
776
- jobId: job.jobId,
777
- soraJobId: job.soraJobId,
778
- createdAt: job.completedAt,
779
- },
780
- });
781
- } catch (galErr) {
782
- this.logger?.warn?.('Gallery save failed (non-fatal)', {
783
- jobId: job.jobId,
784
- error: galErr.message,
785
- });
786
- }
787
- }
788
-
789
- // Broadcast success
790
- this._broadcastJobResult(job, savedToDisk ? resolvedOutputPath : null, videoUrl, savedToDisk);
791
-
792
- // Save to conversation history
793
- await this._saveToConversationHistory(job, false);
794
-
795
- } catch (error) {
796
- this.logger?.error(`Error handling job completion: ${job.jobId}`, error);
797
- await this._handleJobFailed(job, error);
798
- }
799
- }
800
-
801
- /**
802
- * Handle job failure
803
- * @private
804
- */
805
- async _handleJobFailed(job, error) {
806
- // Clear poll timer
807
- const timer = this.pollTimers.get(job.jobId);
808
- if (timer) {
809
- clearTimeout(timer);
810
- this.pollTimers.delete(job.jobId);
811
- }
812
-
813
- // Remove from active jobs
814
- this.activeJobs.delete(job.jobId);
815
-
816
- job.status = 'failed';
817
- job.error = error.message;
818
- job.completedAt = new Date().toISOString();
819
-
820
- this.completedJobs.set(job.jobId, job);
821
-
822
- // Broadcast error
823
- this._broadcastJobStatus(job, 'failed', error.message);
824
-
825
- // Save error to conversation history
826
- await this._saveToConversationHistory(job, true);
827
- }
828
-
829
- /**
830
- * Broadcast job status update
831
- * @private
832
- */
833
- _broadcastJobStatus(job, status, message) {
834
- if (global.loxiaWebServer && job.sessionId) {
835
- global.loxiaWebServer.broadcastToSession(job.sessionId, {
836
- type: 'videoJobStatus',
837
- agentId: job.agentId,
838
- jobId: job.jobId,
839
- soraJobId: job.soraJobId,
840
- status,
841
- message,
842
- prompt: job.prompt,
843
- timestamp: new Date().toISOString()
844
- });
845
- }
846
- }
847
-
848
- /**
849
- * Broadcast job result
850
- * @private
851
- */
852
- _broadcastJobResult(job, localPath, videoUrl, savedToDisk) {
853
- this.logger?.info('📢 Broadcasting video result', {
854
- jobId: job.jobId,
855
- savedToDisk,
856
- localPath,
857
- originalVideoUrl: videoUrl,
858
- hasWebServer: !!global.loxiaWebServer,
859
- sessionId: job.sessionId
860
- });
861
-
862
- if (global.loxiaWebServer && job.sessionId) {
863
- // Convert to web URL if saved locally
864
- let webUrl = videoUrl;
865
- if (savedToDisk && localPath) {
866
- webUrl = this._convertToWebUrl(localPath, job.sessionId);
867
- }
868
-
869
- global.loxiaWebServer.broadcastToSession(job.sessionId, {
870
- type: 'videoGenerated',
871
- agentId: job.agentId,
872
- jobId: job.jobId,
873
- soraJobId: job.soraJobId,
874
- videoUrl: webUrl,
875
- localPath,
876
- prompt: job.prompt,
877
- success: true,
878
- savedToDisk,
879
- isTemporary: !savedToDisk,
880
- width: job.width,
881
- height: job.height,
882
- duration: job.duration,
883
- timestamp: job.completedAt
884
- });
885
-
886
- this.logger?.info('Video generation broadcast sent', {
887
- jobId: job.jobId,
888
- savedToDisk
889
- });
890
- }
891
- }
892
-
893
- /**
894
- * Save result to conversation history
895
- * @private
896
- */
897
- async _saveToConversationHistory(job, isError) {
898
- if (!this.agentPool || !job.agentId) {
899
- return;
900
- }
901
-
902
- try {
903
- const agent = await this.agentPool.getAgent(job.agentId);
904
- if (!agent) {
905
- return;
906
- }
907
-
908
- let message;
909
-
910
- if (isError) {
911
- message = {
912
- id: `vid-error-${job.jobId}`,
913
- role: 'system',
914
- content: `Video generation failed: ${job.error}\n\n**Prompt:** ${job.prompt}`,
915
- timestamp: job.completedAt,
916
- type: 'error',
917
- toolId: 'video-gen',
918
- status: 'failed',
919
- jobId: job.jobId
920
- };
921
- } else {
922
- let content = `Video generated: ${job.prompt}`;
923
-
924
- if (!job.result.savedToDisk) {
925
- content += '\n\nWarning: Video is using a temporary URL (expires in ~24 hours).';
926
- if (job.result.downloadError) {
927
- content += `\n**Error:** ${job.result.downloadError}`;
928
- }
929
- }
930
-
931
- message = {
932
- id: `vid-result-${job.jobId}`,
933
- role: 'assistant',
934
- content,
935
- timestamp: job.completedAt,
936
- // Use persistent URL (session-independent) for conversation history
937
- // This allows videos to work after restart
938
- videoUrl: job.result.savedToDisk
939
- ? this._convertToPersistentUrl(job.persistentFilename || path.basename(job.result.resolvedOutputPath))
940
- : job.result.temporaryUrl,
941
- // Also store filename for fallback lookups
942
- videoFilename: job.persistentFilename || (job.result.resolvedOutputPath ? path.basename(job.result.resolvedOutputPath) : null),
943
- type: 'video-result',
944
- toolId: 'video-gen',
945
- status: 'completed',
946
- isTemporary: !job.result.savedToDisk,
947
- savedToDisk: job.result.savedToDisk,
948
- width: job.width,
949
- height: job.height,
950
- duration: job.duration
951
- };
952
- }
953
-
954
- // Add to full conversation
955
- agent.conversations.full.messages.push(message);
956
- agent.conversations.full.lastUpdated = job.completedAt;
957
-
958
- // Add to current model conversation if exists
959
- if (agent.currentModel && agent.conversations[agent.currentModel]) {
960
- agent.conversations[agent.currentModel].messages.push(message);
961
- agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
962
- }
963
-
964
- agent.lastActivity = job.completedAt;
965
-
966
- await this.agentPool.persistAgentState(job.agentId);
967
-
968
- this.logger?.info('Video result saved to conversation history', {
969
- agentId: job.agentId,
970
- jobId: job.jobId,
971
- isError
972
- });
973
-
974
- // Queue tool result so agent "sees" the completion/failure and can continue
975
- if (isError) {
976
- await this.agentPool.addToolResult(job.agentId, {
977
- toolId: 'video-gen',
978
- status: 'failed',
979
- error: job.error,
980
- result: {
981
- jobId: job.jobId,
982
- prompt: job.prompt
983
- },
984
- timestamp: job.completedAt
985
- });
986
- } else {
987
- await this.agentPool.addToolResult(job.agentId, {
988
- toolId: 'video-gen',
989
- status: 'completed',
990
- result: {
991
- jobId: job.jobId,
992
- prompt: job.prompt,
993
- videoUrl: message.videoUrl,
994
- localPath: job.result.resolvedOutputPath,
995
- savedToDisk: job.result.savedToDisk,
996
- isTemporary: !job.result.savedToDisk,
997
- width: job.width,
998
- height: job.height,
999
- duration: job.duration
1000
- },
1001
- timestamp: job.completedAt
1002
- });
1003
- }
1004
-
1005
- this.logger?.info('Video result queued for agent processing', {
1006
- agentId: job.agentId,
1007
- jobId: job.jobId,
1008
- isError
1009
- });
1010
-
1011
- } catch (error) {
1012
- this.logger?.error('Failed to save video result to conversation history', {
1013
- error: error.message,
1014
- agentId: job.agentId,
1015
- jobId: job.jobId
1016
- });
1017
- }
1018
- }
1019
-
1020
- /**
1021
- * Resolve output path - save to agent's working directory for persistence
1022
- * @private
1023
- */
1024
- async _resolveOutputPath(job) {
1025
- // Use agent's working directory from directoryAccess settings
1026
- // Fall back to projectDir, then cwd
1027
- const workingDir = job.directoryAccess?.workingDirectory || job.projectDir || process.cwd();
1028
- const videosDir = path.join(workingDir, 'generated-videos');
1029
-
1030
- // Create videos directory
1031
- await fs.mkdir(videosDir, { recursive: true });
1032
-
1033
- let filename;
1034
- if (job.outputPath) {
1035
- // User specified a path - use it if within working directory
1036
- if (path.isAbsolute(job.outputPath)) {
1037
- // Absolute path - validate it's within working directory
1038
- const normalizedPath = path.normalize(job.outputPath);
1039
- if (!normalizedPath.startsWith(path.normalize(workingDir))) {
1040
- throw new Error('Output path must be within agent working directory');
1041
- }
1042
- // Use the full path, create parent dirs
1043
- await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
1044
- job.saveToProject = true;
1045
- job.persistentFilename = path.basename(normalizedPath);
1046
- return normalizedPath;
1047
- } else {
1048
- // Relative path - resolve relative to working directory
1049
- const resolvedPath = path.normalize(path.join(workingDir, job.outputPath));
1050
- if (!resolvedPath.startsWith(path.normalize(workingDir))) {
1051
- throw new Error('Path traversal detected');
1052
- }
1053
- await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
1054
- job.saveToProject = true;
1055
- job.persistentFilename = path.basename(resolvedPath);
1056
- return resolvedPath;
1057
- }
1058
- } else {
1059
- // Generate filename from job ID in generated-videos folder
1060
- filename = `video-${job.jobId}.mp4`;
1061
- }
1062
-
1063
- const resolvedPath = path.join(videosDir, filename);
1064
-
1065
- this.logger?.info('📁 Video will be saved to agent directory', {
1066
- workingDir,
1067
- filename,
1068
- resolvedPath
1069
- });
1070
-
1071
- // Mark job as saved to project for correct URL generation
1072
- job.saveToProject = true;
1073
- job.persistentFilename = filename;
1074
-
1075
- return resolvedPath;
1076
- }
1077
-
1078
- /**
1079
- * Download video from URL
1080
- * @private
1081
- */
1082
- async _downloadVideo(videoUrl, outputPath, sessionId) {
1083
- try {
1084
- // Get API key for authentication with backend
1085
- let apiKey = null;
1086
- if (this.aiService?.apiKeyManager) {
1087
- const keys = this.aiService.apiKeyManager.getKeysForRequest(null);
1088
- apiKey = keys.loxiaApiKey;
1089
- }
1090
- if (!apiKey) {
1091
- apiKey = process.env.LOXIA_API_KEY;
1092
- }
1093
-
1094
- const headers = {};
1095
- if (apiKey) {
1096
- headers['Authorization'] = `Bearer ${apiKey}`;
1097
- }
1098
-
1099
- this.logger?.info('📥 Downloading video', {
1100
- url: videoUrl,
1101
- hasApiKey: !!apiKey,
1102
- apiKeySource: apiKey ? (this.aiService?.apiKeyManager ? 'apiKeyManager' : 'env') : 'none',
1103
- outputPath,
1104
- sessionId
1105
- });
1106
-
1107
- const response = await fetch(videoUrl, {
1108
- headers,
1109
- signal: AbortSignal.timeout(VIDEO_CONFIG.DOWNLOAD_TIMEOUT)
1110
- });
1111
-
1112
- if (!response.ok) {
1113
- const errorText = await response.text().catch(() => '');
1114
- throw new Error(`Failed to download video: HTTP ${response.status} - ${errorText}`);
1115
- }
1116
-
1117
- const buffer = Buffer.from(await response.arrayBuffer());
1118
- await fs.writeFile(outputPath, buffer);
1119
-
1120
- this.logger?.info(`Video saved to: ${outputPath}`);
1121
-
1122
- } catch (error) {
1123
- this.logger?.error('❌ Video download failed', {
1124
- errorName: error.name,
1125
- errorMessage: error.message,
1126
- url: videoUrl,
1127
- outputPath
1128
- });
1129
-
1130
- if (error.name === 'TimeoutError') {
1131
- throw new Error('Video download timeout');
1132
- } else if (error.name === 'TypeError') {
1133
- throw new Error(`Network error: ${error.message}`);
1134
- } else {
1135
- throw new Error(`Download failed: ${error.message}`);
1136
- }
1137
- }
1138
- }
1139
-
1140
- /**
1141
- * Schedule cleanup of temp file
1142
- * @private
1143
- */
1144
- _scheduleCleanup(filePath, jobId) {
1145
- const timer = setTimeout(async () => {
1146
- try {
1147
- await fs.unlink(filePath);
1148
- this.logger?.debug(`Cleaned up temp video: ${filePath}`);
1149
- this.cleanupTimers.delete(jobId);
1150
- } catch (error) {
1151
- // File might already be deleted, ignore
1152
- }
1153
- }, VIDEO_CONFIG.TEMP_CLEANUP_MS);
1154
-
1155
- this.cleanupTimers.set(jobId, timer);
1156
- }
1157
-
1158
- /**
1159
- * Convert local file path to web-accessible URL
1160
- * @private
1161
- */
1162
- _convertToWebUrl(localPath, sessionId) {
1163
- const filename = path.basename(localPath);
1164
- const port = global.loxiaWebServer?.port || 8080;
1165
- let host = global.loxiaWebServer?.host || 'localhost';
1166
-
1167
- if (host === '0.0.0.0') {
1168
- host = 'localhost';
1169
- }
1170
-
1171
- const webUrl = `http://${host}:${port}/api/videos/${sessionId}/${filename}`;
1172
-
1173
- this.logger?.info('🔗 Converting local path to web URL', {
1174
- localPath,
1175
- sessionId,
1176
- webUrl
1177
- });
1178
-
1179
- return webUrl;
1180
- }
1181
-
1182
- /**
1183
- * Convert filename to persistent (session-independent) URL
1184
- * These URLs work after browser refresh and system restart
1185
- * @private
1186
- */
1187
- _convertToPersistentUrl(filename) {
1188
- const port = global.loxiaWebServer?.port || 8080;
1189
- let host = global.loxiaWebServer?.host || 'localhost';
1190
-
1191
- if (host === '0.0.0.0') {
1192
- host = 'localhost';
1193
- }
1194
-
1195
- // Use the session-independent endpoint that searches all agent directories
1196
- const persistentUrl = `http://${host}:${port}/api/generated-videos/${filename}`;
1197
-
1198
- this.logger?.info('🔗 Generated persistent URL for video', {
1199
- filename,
1200
- persistentUrl
1201
- });
1202
-
1203
- return persistentUrl;
1204
- }
1205
-
1206
- /**
1207
- * Estimate wait time based on queue
1208
- * @private
1209
- */
1210
- _estimateWaitTime() {
1211
- const avgGenerationTime = 300; // 5 minutes in seconds
1212
- const queuePosition = this.queue.length;
1213
- const activeJobs = this.activeJobs.size;
1214
-
1215
- if (queuePosition === 0 && activeJobs === 0) {
1216
- return '~5 minutes';
1217
- }
1218
-
1219
- // Calculate based on queue position and concurrent limit
1220
- const waitingJobs = queuePosition + activeJobs;
1221
- const batches = Math.ceil(waitingJobs / VIDEO_CONFIG.MAX_CONCURRENT);
1222
- const estimatedSeconds = batches * avgGenerationTime;
1223
-
1224
- const minutes = Math.floor(estimatedSeconds / 60);
1225
-
1226
- if (minutes >= 60) {
1227
- const hours = Math.floor(minutes / 60);
1228
- const remainingMinutes = minutes % 60;
1229
- return `~${hours}h ${remainingMinutes}m`;
1230
- }
1231
-
1232
- return `~${minutes} minutes`;
1233
- }
1234
-
1235
- /**
1236
- * Generate unique job ID
1237
- * @private
1238
- */
1239
- _generateJobId() {
1240
- return `vid-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1241
- }
1242
-
1243
- /**
1244
- * Get job status
1245
- * @param {string} jobId - Job ID
1246
- * @returns {Object} Job status
1247
- */
1248
- getJobStatus(jobId) {
1249
- // Check completed jobs
1250
- if (this.completedJobs.has(jobId)) {
1251
- return this.completedJobs.get(jobId);
1252
- }
1253
-
1254
- // Check active jobs
1255
- if (this.activeJobs.has(jobId)) {
1256
- return this.activeJobs.get(jobId);
1257
- }
1258
-
1259
- // Check queue
1260
- const queuedJob = this.queue.find(job => job.jobId === jobId);
1261
- if (queuedJob) {
1262
- return queuedJob;
1263
- }
1264
-
1265
- return {
1266
- jobId,
1267
- status: 'not_found'
1268
- };
1269
- }
1270
-
1271
- /**
1272
- * Cleanup on shutdown
1273
- */
1274
- async cleanup() {
1275
- this.logger?.info('Shutting down VideoTool');
1276
-
1277
- // Clear all cleanup timers
1278
- for (const timer of this.cleanupTimers.values()) {
1279
- clearTimeout(timer);
1280
- }
1281
- this.cleanupTimers.clear();
1282
-
1283
- // Clear all poll timers
1284
- for (const timer of this.pollTimers.values()) {
1285
- clearTimeout(timer);
1286
- }
1287
- this.pollTimers.clear();
1288
-
1289
- // Mark queued jobs as cancelled
1290
- for (const job of this.queue) {
1291
- job.status = 'cancelled';
1292
- }
1293
- this.queue = [];
1294
-
1295
- // Mark active jobs as cancelled
1296
- for (const job of this.activeJobs.values()) {
1297
- job.status = 'cancelled';
1298
- }
1299
- this.activeJobs.clear();
1300
- }
1301
- }
1302
-
1303
- export default VideoTool;
1
+ /**
2
+ * @file tools/videoTool.js
3
+ * @description Tool for generating videos 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
+
12
+ /**
13
+ * Configuration constants for video generation.
14
+ *
15
+ * Per-model limits (allowed resolutions, allowed durations, whether
16
+ * variants are supported) are NOT hardcoded here — they live in the
17
+ * model catalog (`video_config` block on each video-generation row) and
18
+ * are enforced authoritatively by the backend route. The CLI sends the
19
+ * request and surfaces the backend's 400 verbatim if the inputs aren't
20
+ * compatible with the chosen model. This keeps the CLI provider-agnostic
21
+ * (Sora 1, Sora 2, future video models all just work) and avoids
22
+ * silently-stale validation tables here.
23
+ *
24
+ * The constants below are CLI-side controls only (queue/concurrency/
25
+ * polling/temp file lifetime/timeout). No model-specific values.
26
+ */
27
+ const VIDEO_CONFIG = {
28
+ DEFAULT_MODEL: null, // resolved dynamically by aiService
29
+ DEFAULT_WIDTH: 1280, // sensible default; any video model in the
30
+ DEFAULT_HEIGHT: 720, // catalog with `1280x720` accepts these
31
+ DEFAULT_DURATION: 4, // Sora 2's smallest unit; safe across providers
32
+ DEFAULT_VARIANTS: 1, // single video per job (Sora 2; Sora 1 supported up to 4)
33
+ MAX_PROMPT_LENGTH: 4000,
34
+ // Hard CLI safety bounds — let the backend/catalog be authoritative on
35
+ // *which specific values* are allowed for the chosen model, but reject
36
+ // obviously-bogus inputs locally so the user gets immediate feedback.
37
+ ABSOLUTE_MIN_DURATION: 1,
38
+ ABSOLUTE_MAX_DURATION: 60,
39
+ ABSOLUTE_MAX_VARIANTS: 8,
40
+ // Queue + scheduler controls (CLI-side only).
41
+ MAX_CONCURRENT: 2,
42
+ QUEUE_LIMIT: 5,
43
+ POLL_INTERVAL: 5000, // ms between status checks
44
+ MAX_POLL_TIME: 600000, // 10 minutes max wait per job
45
+ TEMP_CLEANUP_MS: 86400000, // 24 h (Sora 2 expires content after 24 h anyway)
46
+ DOWNLOAD_TIMEOUT: 300000 // 5 minutes for video download
47
+ };
48
+
49
+ /**
50
+ * VideoTool - Generate videos using Sora 2 AI model
51
+ * Supports async job-based processing with polling
52
+ */
53
+ export class VideoTool extends BaseTool {
54
+ constructor(config = {}, logger = null) {
55
+ super(config, logger);
56
+
57
+ // Override tool ID
58
+ this.id = 'video-gen';
59
+
60
+ // Job queue and tracking
61
+ this.queue = [];
62
+ this.activeJobs = new Map(); // Currently processing jobs (max 2)
63
+ this.completedJobs = new Map();
64
+ this.isProcessing = false;
65
+
66
+ // AIService will be injected later
67
+ this.aiService = null;
68
+
69
+ // AgentPool will be injected later (for saving to conversation history)
70
+ this.agentPool = null;
71
+
72
+ // Temp directory for videos
73
+ this.tempDir = path.join(os.tmpdir(), 'loxia-videos');
74
+
75
+ // Cleanup timers
76
+ this.cleanupTimers = new Map();
77
+
78
+ // Polling timers
79
+ this.pollTimers = new Map();
80
+ }
81
+
82
+ /**
83
+ * Snapshot of the job state for the web-UI `/api/videos/jobs` endpoint.
84
+ * Serialises the three job maps into a stable JSON shape that the
85
+ * Video Jobs page can render + poll on. Intentionally excludes the
86
+ * large `result` blobs for completed jobs to keep the list light —
87
+ * consumers that want the full result hit the file-serve URL instead.
88
+ *
89
+ * @returns {{
90
+ * queued: Array<JobSummary>,
91
+ * active: Array<JobSummary>,
92
+ * completed: Array<JobSummary>,
93
+ * limits: { concurrency: number, queue: number },
94
+ * }}
95
+ */
96
+ getJobsSnapshot() {
97
+ const summarize = (j) => ({
98
+ jobId: j.jobId,
99
+ agentId: j.agentId,
100
+ sessionId: j.sessionId,
101
+ prompt: typeof j.prompt === 'string' ? j.prompt.slice(0, 300) : '',
102
+ model: j.model,
103
+ width: j.width,
104
+ height: j.height,
105
+ duration: j.duration,
106
+ variants: j.variants,
107
+ status: j.status || 'unknown',
108
+ soraJobId: j.soraJobId || null,
109
+ outputPath: j.outputPath || null,
110
+ createdAt: j.createdAt || null,
111
+ startedAt: j.startedAt || null,
112
+ completedAt: j.completedAt || null,
113
+ error: j.error || null,
114
+ // Best-effort URL to the final video file if known. The existing
115
+ // /api/videos/:sessionId/:filename serve route locates the file.
116
+ videoUrl: (j.status === 'completed' && j.outputPath && j.sessionId)
117
+ ? `/api/videos/${encodeURIComponent(j.sessionId)}/${encodeURIComponent(
118
+ typeof j.outputPath === 'string' ? j.outputPath.split(/[\\/]/).pop() : ''
119
+ )}`
120
+ : null,
121
+ });
122
+ return {
123
+ queued: Array.from(this.queue).map(summarize),
124
+ active: Array.from(this.activeJobs.values()).map(summarize),
125
+ completed: Array.from(this.completedJobs.values()).map(summarize),
126
+ limits: {
127
+ // Was reading the non-existent VIDEO_CONFIG.CONCURRENCY_LIMIT and
128
+ // falling through to `activeJobs.size + queue.length`, which is
129
+ // 0 when idle and produced a misleading `"concurrency": 0` in the
130
+ // /api/videos/jobs snapshot (the real cap is MAX_CONCURRENT=2).
131
+ concurrency: VIDEO_CONFIG?.MAX_CONCURRENT ?? null,
132
+ queue: VIDEO_CONFIG?.QUEUE_LIMIT ?? null,
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Cancel a video generation job by id. Removes from queue if still
139
+ * pending, marks active jobs for cancellation (best-effort — an
140
+ * in-flight Sora API call will still complete, but we stop polling
141
+ * and stop persisting the result). Returns { success, state }.
142
+ *
143
+ * Completed and already-failed jobs are not cancellable.
144
+ */
145
+ cancelJob(jobId) {
146
+ // Queued — just remove from the queue.
147
+ const qIdx = this.queue.findIndex(j => j.jobId === jobId);
148
+ if (qIdx !== -1) {
149
+ const [job] = this.queue.splice(qIdx, 1);
150
+ job.status = 'cancelled';
151
+ job.completedAt = new Date().toISOString();
152
+ this.completedJobs.set(job.jobId, job);
153
+ this.logger?.info('Video job cancelled (was queued)', { jobId });
154
+ return { success: true, state: 'queued' };
155
+ }
156
+ // Active — mark + stop polling.
157
+ if (this.activeJobs.has(jobId)) {
158
+ const job = this.activeJobs.get(jobId);
159
+ job.status = 'cancelled';
160
+ job.completedAt = new Date().toISOString();
161
+ const timer = this.pollTimers?.get(jobId);
162
+ if (timer) { clearTimeout(timer); this.pollTimers.delete(jobId); }
163
+ this.activeJobs.delete(jobId);
164
+ this.completedJobs.set(jobId, job);
165
+ this.logger?.info('Video job cancelled (was active)', { jobId });
166
+ return { success: true, state: 'active' };
167
+ }
168
+ // Completed / unknown.
169
+ return { success: false, error: 'Job not found or already terminal' };
170
+ }
171
+
172
+ /**
173
+ * Set AI service for video generation
174
+ * @param {AIService} aiService - AI service instance
175
+ */
176
+ setAIService(aiService) {
177
+ this.aiService = aiService;
178
+ this.logger?.info('AI Service set for VideoTool');
179
+ }
180
+
181
+ /**
182
+ * Set Agent Pool for saving results to conversation history
183
+ * @param {AgentPool} agentPool - AgentPool instance
184
+ */
185
+ setAgentPool(agentPool) {
186
+ this.agentPool = agentPool;
187
+ this.logger?.info('AgentPool set for VideoTool');
188
+ }
189
+
190
+ /**
191
+ * Get tool description for agent system prompt
192
+ * @returns {string} Formatted tool description
193
+ */
194
+ getDescription() {
195
+ return `Tool: Video Generator — generate videos from text using the platform's video model.
196
+
197
+ **Purpose:** Generate short videos from text descriptions. Videos are saved to disk and made available in chat. The active video model is resolved automatically from the model catalog (currently Sora 2 on this deployment); model-specific limits like allowed resolutions and durations come from the catalog and are enforced by the backend.
198
+
199
+ **CRITICAL: Automatic Execution**
200
+ - ANY \`\`\`json block with "toolId": "video-gen" will be EXECUTED IMMEDIATELY.
201
+ - Just output the command when you want to generate a video.
202
+ - Video generation takes minutes — a job ID is returned immediately and the file lands when the job finishes.
203
+
204
+ **USAGE:**
205
+ \`\`\`json
206
+ {
207
+ "toolId": "video-gen",
208
+ "parameters": {
209
+ "prompt": "Detailed description of the video",
210
+ "outputPath": "videos/filename.mp4",
211
+ "width": 1280,
212
+ "height": 720,
213
+ "duration": 4
214
+ }
215
+ }
216
+ \`\`\`
217
+
218
+ **Parameters:**
219
+ - **prompt** (required): Detailed description of the video.
220
+ - **outputPath** (optional): Where to save the .mp4 (permanent). Omit for a temp file.
221
+ - **width** / **height** (optional): Resolution. The catalog defines allowed pairs per model; the backend rejects unsupported pairs with a clear 400 listing valid values.
222
+ - **duration** (optional): Duration in seconds. The catalog defines allowed values per model.
223
+ - **variants** (optional): Number of video variants. Some models (e.g. Sora 2) only return one video per job; the backend will reject \`variants > 1\` with a clear message naming the model's limit.
224
+ - **model** (optional): Override the auto-resolved model. Must be a video-generation model in the catalog.
225
+
226
+ **Current model limits (Sora 2):**
227
+ - Resolutions: \`720x1280\`, \`1280x720\`, \`1024x1792\`, \`1792x1024\` (16:9 / 9:16 only — no square or 480p)
228
+ - Durations: \`4\`, \`8\`, or \`12\` seconds (those exact values, no others)
229
+ - Variants: 1 per job — request multiple separately if you need more
230
+
231
+ These limits live in the catalog row, not in this tool. If the model rotates (e.g. Sora 3) or a new video model is added, the allowed values update automatically and you'll see them in the backend's 400 if you submit unsupported inputs.
232
+
233
+ **EXAMPLE:**
234
+ User: "create a video of a cat playing"
235
+ You output:
236
+ \`\`\`json
237
+ {
238
+ "toolId": "video-gen",
239
+ "parameters": {
240
+ "prompt": "A fluffy orange tabby cat with white paws crouches low on a sunlit hardwood floor, eyes locked on a red laser dot. The cat pounces forward, slides slightly on the polished wood, then quickly pivots to chase the dot as it darts away. Warm afternoon sunlight streams through sheer curtains, casting soft golden highlights on the cat's fur. Cozy living room with a beige sofa in the background.",
241
+ "outputPath": "videos/cat-playing.mp4",
242
+ "width": 1280,
243
+ "height": 720,
244
+ "duration": 4
245
+ }
246
+ }
247
+ \`\`\`
248
+
249
+ **Prompt Guidelines (IMPORTANT):**
250
+ Sora simulates a physical world — write prompts as narratives, not camera commands. Use the CAST method: describe the **Character** (appearance, clothing, posture), **Action** (broken into beats: "takes three steps, pauses, looks back"), **Setting** (time of day, weather, specific objects), and **Tone/Atmosphere** (lighting quality, color palette, mood). Be specific and sensory — replace "beautiful street" with "rain-slick Tokyo asphalt reflecting neon signs." Keep prompts under 120 words, focus on ONE action per clip, and use simple camera cues only if needed ("wide shot," "close-up"). Anchor lighting explicitly ("warm golden hour sunlight with soft shadows") and name colors (teal, amber, magenta) for palette consistency. For character consistency across clips, repeat the same distinctive details (clothing colors, accessories, features) in each prompt. Avoid real people, copyrighted characters, and sensitive content.
251
+
252
+ **Notes:**
253
+ - Videos take 1-3 minutes to generate (Sora 2 short clips).
254
+ - Max ${VIDEO_CONFIG.QUEUE_LIMIT} videos in queue, max ${VIDEO_CONFIG.MAX_CONCURRENT} concurrent jobs.
255
+ - Generated videos expire on the provider after 24 hours; if you need to keep them, set an \`outputPath\` so the local copy persists.`;
256
+ }
257
+
258
+ /**
259
+ * Parse video generation parameters
260
+ * @param {string|Object} content - Raw content or parsed object
261
+ * @returns {Object} Parsed parameters
262
+ */
263
+ parseParameters(content) {
264
+ // Handle JSON format
265
+ if (typeof content === 'object' && content !== null) {
266
+ return this._parseJSONParams(content);
267
+ }
268
+
269
+ // Handle string format
270
+ if (typeof content === 'string') {
271
+ const trimmed = content.trim();
272
+
273
+ // Try to parse as JSON first
274
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
275
+ try {
276
+ const parsed = JSON.parse(trimmed);
277
+ return this._parseJSONParams(parsed);
278
+ } catch {
279
+ // Not valid JSON, fall through to XML parsing
280
+ }
281
+ }
282
+
283
+ // Parse as XML
284
+ return this._parseXMLParams(content);
285
+ }
286
+
287
+ throw new Error('Invalid parameter format');
288
+ }
289
+
290
+ /**
291
+ * Parse JSON parameters
292
+ * @private
293
+ */
294
+ _parseJSONParams(obj) {
295
+ // Handle parameters wrapper (when called via toolId/parameters structure)
296
+ if (obj.parameters) {
297
+ obj = obj.parameters;
298
+ }
299
+
300
+ // Check for batch mode
301
+ if (obj.batch && Array.isArray(obj.batch)) {
302
+ return {
303
+ batch: true,
304
+ videos: obj.batch.map(vid => this._parseVideoParams(vid))
305
+ };
306
+ }
307
+
308
+ return {
309
+ batch: false,
310
+ videos: [this._parseVideoParams(obj)]
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Parse XML parameters
316
+ * @private
317
+ */
318
+ _parseXMLParams(content) {
319
+ const params = { batch: false, videos: [] };
320
+
321
+ // Check for batch mode
322
+ const batchMatch = /<batch>([\s\S]*?)<\/batch>/i.exec(content);
323
+
324
+ if (batchMatch) {
325
+ params.batch = true;
326
+ const batchContent = batchMatch[1];
327
+
328
+ // Extract individual <video> blocks
329
+ const videoRegex = /<video>([\s\S]*?)<\/video>/gi;
330
+ let match;
331
+
332
+ while ((match = videoRegex.exec(batchContent)) !== null) {
333
+ params.videos.push(this._parseXMLVideo(match[1]));
334
+ }
335
+ } else {
336
+ // Single video mode
337
+ params.videos.push(this._parseXMLVideo(content));
338
+ }
339
+
340
+ if (params.videos.length === 0) {
341
+ throw new Error('No valid video parameters found');
342
+ }
343
+
344
+ return params;
345
+ }
346
+
347
+ /**
348
+ * Parse single video parameters from object
349
+ * @private
350
+ */
351
+ _parseVideoParams(obj) {
352
+ const outputPath = obj.outputPath || obj['output-path'] || null;
353
+
354
+ return {
355
+ prompt: obj.prompt || '',
356
+ outputPath: outputPath,
357
+ saveToProject: outputPath !== null,
358
+ model: obj.model || VIDEO_CONFIG.DEFAULT_MODEL,
359
+ width: parseInt(obj.width) || VIDEO_CONFIG.DEFAULT_WIDTH,
360
+ height: parseInt(obj.height) || VIDEO_CONFIG.DEFAULT_HEIGHT,
361
+ duration: parseInt(obj.duration) || VIDEO_CONFIG.DEFAULT_DURATION,
362
+ variants: parseInt(obj.variants) || VIDEO_CONFIG.DEFAULT_VARIANTS
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Parse single video parameters from XML string
368
+ * @private
369
+ */
370
+ _parseXMLVideo(xmlContent) {
371
+ const extractTag = (tag) => {
372
+ const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i');
373
+ const match = regex.exec(xmlContent);
374
+ return match ? match[1].trim() : null;
375
+ };
376
+
377
+ const outputPath = extractTag('output-path') || null;
378
+
379
+ return {
380
+ prompt: extractTag('prompt') || '',
381
+ outputPath: outputPath,
382
+ saveToProject: outputPath !== null,
383
+ model: extractTag('model') || VIDEO_CONFIG.DEFAULT_MODEL,
384
+ width: parseInt(extractTag('width')) || VIDEO_CONFIG.DEFAULT_WIDTH,
385
+ height: parseInt(extractTag('height')) || VIDEO_CONFIG.DEFAULT_HEIGHT,
386
+ duration: parseInt(extractTag('duration')) || VIDEO_CONFIG.DEFAULT_DURATION,
387
+ variants: parseInt(extractTag('variants')) || VIDEO_CONFIG.DEFAULT_VARIANTS
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Execute video generation
393
+ * @param {Object|string} params - Parsed parameters object OR raw XML/JSON string
394
+ * @param {Object} context - Execution context
395
+ * @returns {Promise<Object>} Execution result
396
+ */
397
+ async execute(params, context = {}) {
398
+ try {
399
+ const { agentId, projectDir, directoryAccess, sessionId } = context;
400
+
401
+ // Auto-detect and parse inputs
402
+ if (typeof params === 'string') {
403
+ this.logger?.info('VideoTool: Auto-parsing string parameters');
404
+ params = this.parseParameters(params);
405
+ } else if (typeof params === 'object' && params !== null && !params.videos) {
406
+ this.logger?.info('VideoTool: Normalizing object parameters');
407
+ params = this.parseParameters(params);
408
+ }
409
+
410
+ // Validate parameters
411
+ this._validateParameters(params);
412
+
413
+ // Queue videos
414
+ const jobIds = [];
415
+
416
+ for (const videoParams of params.videos) {
417
+ // Create job
418
+ const jobId = this._generateJobId();
419
+
420
+ const job = {
421
+ jobId,
422
+ agentId,
423
+ sessionId,
424
+ prompt: videoParams.prompt,
425
+ outputPath: videoParams.outputPath,
426
+ saveToProject: videoParams.saveToProject,
427
+ model: videoParams.model,
428
+ width: videoParams.width,
429
+ height: videoParams.height,
430
+ duration: videoParams.duration,
431
+ variants: videoParams.variants,
432
+ projectDir: projectDir || process.cwd(),
433
+ directoryAccess,
434
+ // Per-agent video-gen config (reaches the async processor
435
+ // through the job). Used to gate `saveToGallery` below.
436
+ toolConfig: context?.toolConfig || null,
437
+ status: 'queued',
438
+ soraJobId: null, // Will be set when submitted to Sora
439
+ createdAt: new Date().toISOString()
440
+ };
441
+
442
+ // Check queue limit
443
+ if (this.queue.length >= VIDEO_CONFIG.QUEUE_LIMIT) {
444
+ return {
445
+ success: false,
446
+ error: `Queue limit reached (${VIDEO_CONFIG.QUEUE_LIMIT} videos). Please wait for current jobs to complete.`,
447
+ queueLength: this.queue.length
448
+ };
449
+ }
450
+
451
+ this.queue.push(job);
452
+ jobIds.push(jobId);
453
+
454
+ this.logger?.info(`Video generation job queued: ${jobId}`, {
455
+ prompt: videoParams.prompt.substring(0, 50) + '...',
456
+ queuePosition: this.queue.length
457
+ });
458
+ }
459
+
460
+ // Start processing if not already running
461
+ if (!this.isProcessing) {
462
+ this._processQueue().catch(err => {
463
+ this.logger?.error('Queue processing error:', err);
464
+ });
465
+ }
466
+
467
+ // Return immediate response
468
+ return {
469
+ success: true,
470
+ jobIds,
471
+ queueLength: this.queue.length,
472
+ activeJobs: this.activeJobs.size,
473
+ message: params.batch
474
+ ? `${jobIds.length} videos queued for generation`
475
+ : 'Video queued for generation',
476
+ estimatedWaitTime: this._estimateWaitTime()
477
+ };
478
+
479
+ } catch (error) {
480
+ this.logger?.error('Video generation error:', error);
481
+ return {
482
+ success: false,
483
+ error: error.message
484
+ };
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Sanity-check inputs before submitting to the backend. Per-model limits
490
+ * (which exact resolutions and durations a given video model accepts)
491
+ * are NOT enforced here — the catalog declares them and the backend
492
+ * route validates against the catalog row. This keeps the CLI
493
+ * provider-agnostic; if the inputs aren't compatible with the chosen
494
+ * model, the backend's 400 surfaces back through the queue's `error`
495
+ * field with a clear message naming the allowed values.
496
+ *
497
+ * Local checks below cover only "obviously wrong" inputs that no video
498
+ * provider would accept — empty prompts, non-positive dimensions, etc.
499
+ *
500
+ * @private
501
+ */
502
+ _validateParameters(params) {
503
+ if (!params.videos || params.videos.length === 0) {
504
+ throw new Error('No videos specified');
505
+ }
506
+
507
+ for (const vid of params.videos) {
508
+ if (!vid.prompt || vid.prompt.trim().length === 0) {
509
+ throw new Error('Video prompt is required');
510
+ }
511
+ if (vid.prompt.length > VIDEO_CONFIG.MAX_PROMPT_LENGTH) {
512
+ throw new Error(`Prompt too long (max ${VIDEO_CONFIG.MAX_PROMPT_LENGTH} characters)`);
513
+ }
514
+
515
+ // Width/height must be positive integers — backend + provider
516
+ // decide which exact pairs are valid for the chosen model.
517
+ if (vid.width != null && (!Number.isInteger(vid.width) || vid.width <= 0)) {
518
+ throw new Error(`Invalid width: ${vid.width}. Must be a positive integer.`);
519
+ }
520
+ if (vid.height != null && (!Number.isInteger(vid.height) || vid.height <= 0)) {
521
+ throw new Error(`Invalid height: ${vid.height}. Must be a positive integer.`);
522
+ }
523
+
524
+ // Duration: only catch absurd values here; the catalog declares
525
+ // the per-model allowed list (e.g. Sora 2: {4, 8, 12} seconds).
526
+ if (vid.duration != null) {
527
+ if (typeof vid.duration !== 'number' || vid.duration < VIDEO_CONFIG.ABSOLUTE_MIN_DURATION || vid.duration > VIDEO_CONFIG.ABSOLUTE_MAX_DURATION) {
528
+ throw new Error(`Invalid duration: ${vid.duration}. Must be a number between ${VIDEO_CONFIG.ABSOLUTE_MIN_DURATION} and ${VIDEO_CONFIG.ABSOLUTE_MAX_DURATION} seconds. The chosen model may support a narrower set — see /llm/models.`);
529
+ }
530
+ }
531
+
532
+ // Variants: same approach — sanity-only. Some models (e.g. Sora 2)
533
+ // only return a single video per job; the backend will 400 if the
534
+ // model doesn't support multiple variants.
535
+ if (vid.variants != null) {
536
+ if (!Number.isInteger(vid.variants) || vid.variants < 1 || vid.variants > VIDEO_CONFIG.ABSOLUTE_MAX_VARIANTS) {
537
+ throw new Error(`Invalid variants: ${vid.variants}. Must be an integer between 1 and ${VIDEO_CONFIG.ABSOLUTE_MAX_VARIANTS}. The chosen model may only support 1.`);
538
+ }
539
+ }
540
+
541
+ if (vid.outputPath) {
542
+ const ext = path.extname(vid.outputPath).toLowerCase();
543
+ if (ext && ext !== '.mp4') {
544
+ throw new Error(`Invalid format: ${ext}. Only .mp4 is supported`);
545
+ }
546
+ }
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Process the video generation queue
552
+ * @private
553
+ */
554
+ async _processQueue() {
555
+ if (this.isProcessing) {
556
+ return;
557
+ }
558
+
559
+ this.isProcessing = true;
560
+
561
+ while (this.queue.length > 0 || this.activeJobs.size > 0) {
562
+ // Start new jobs if under concurrent limit
563
+ while (this.queue.length > 0 && this.activeJobs.size < VIDEO_CONFIG.MAX_CONCURRENT) {
564
+ const job = this.queue.shift();
565
+ await this._startVideoJob(job);
566
+ }
567
+
568
+ // Wait a bit before checking again
569
+ await new Promise(resolve => setTimeout(resolve, 1000));
570
+ }
571
+
572
+ this.isProcessing = false;
573
+ }
574
+
575
+ /**
576
+ * Start a video generation job
577
+ * @private
578
+ */
579
+ async _startVideoJob(job) {
580
+ this.logger?.info(`Starting video generation job: ${job.jobId}`);
581
+
582
+ try {
583
+ job.status = 'submitting';
584
+
585
+ // Check if AI service is available
586
+ if (!this.aiService) {
587
+ throw new Error('AI service not available. Video generation requires AI service.');
588
+ }
589
+
590
+ // Submit to Sora API
591
+ const options = {
592
+ width: job.width,
593
+ height: job.height,
594
+ duration: job.duration,
595
+ variants: job.variants,
596
+ sessionId: job.sessionId
597
+ };
598
+
599
+ const result = await this.aiService.generateVideo(job.prompt, options);
600
+
601
+ // Store Sora job ID
602
+ job.soraJobId = result.jobId;
603
+ job.status = 'processing';
604
+ job.submittedAt = new Date().toISOString();
605
+
606
+ // Add to active jobs
607
+ this.activeJobs.set(job.jobId, job);
608
+
609
+ this.logger?.info(`Video job submitted to Sora: ${job.soraJobId}`, {
610
+ localJobId: job.jobId
611
+ });
612
+
613
+ // Broadcast status update
614
+ this._broadcastJobStatus(job, 'processing', 'Video generation started');
615
+
616
+ // Start polling for completion
617
+ this._pollJobStatus(job);
618
+
619
+ } catch (error) {
620
+ this.logger?.error(`Failed to start video job: ${job.jobId}`, error);
621
+
622
+ job.status = 'failed';
623
+ job.error = error.message;
624
+ job.completedAt = new Date().toISOString();
625
+
626
+ this.completedJobs.set(job.jobId, job);
627
+
628
+ // Broadcast error
629
+ this._broadcastJobStatus(job, 'failed', error.message);
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Poll for job completion
635
+ * @private
636
+ */
637
+ async _pollJobStatus(job) {
638
+ const startTime = Date.now();
639
+
640
+ const poll = async () => {
641
+ try {
642
+ // Check if we've exceeded max poll time
643
+ if (Date.now() - startTime > VIDEO_CONFIG.MAX_POLL_TIME) {
644
+ throw new Error('Video generation timeout - exceeded maximum wait time');
645
+ }
646
+
647
+ // Get status from Sora - pass sessionId for API key retrieval
648
+ const status = await this.aiService.getVideoJobStatus(job.soraJobId, {
649
+ sessionId: job.sessionId,
650
+ model: job.model
651
+ });
652
+
653
+ this.logger?.debug(`Video job status: ${status.status}`, {
654
+ jobId: job.jobId,
655
+ soraJobId: job.soraJobId,
656
+ sessionId: job.sessionId
657
+ });
658
+
659
+ if (status.status === 'succeeded' || status.status === 'completed') {
660
+ // Job completed successfully
661
+ await this._handleJobComplete(job, status);
662
+ } else if (status.status === 'failed' || status.status === 'cancelled') {
663
+ // Job failed
664
+ throw new Error(status.error || 'Video generation failed');
665
+ } else {
666
+ // Still processing - poll again
667
+ const timer = setTimeout(() => poll(), VIDEO_CONFIG.POLL_INTERVAL);
668
+ this.pollTimers.set(job.jobId, timer);
669
+ }
670
+ } catch (error) {
671
+ this.logger?.error(`Video job failed: ${job.jobId}`, error);
672
+ await this._handleJobFailed(job, error);
673
+ }
674
+ };
675
+
676
+ // Start polling
677
+ poll();
678
+ }
679
+
680
+ /**
681
+ * Handle successful job completion
682
+ * @private
683
+ */
684
+ async _handleJobComplete(job, status) {
685
+ this.logger?.info(`Video generation completed: ${job.jobId}`);
686
+
687
+ try {
688
+ // Clear poll timer
689
+ const timer = this.pollTimers.get(job.jobId);
690
+ if (timer) {
691
+ clearTimeout(timer);
692
+ this.pollTimers.delete(job.jobId);
693
+ }
694
+
695
+ // Remove from active jobs
696
+ this.activeJobs.delete(job.jobId);
697
+
698
+ // Get generation ID from status - Sora returns generation ID, not direct URL
699
+ const generationId = status.generationId || status.generations?.[0]?.id;
700
+
701
+ if (!generationId) {
702
+ throw new Error('No generation ID received from Sora');
703
+ }
704
+
705
+ // Construct video download URL through our backend proxy
706
+ // The backend will authenticate with Sora and stream the video
707
+ const backendUrl = this.aiService?.baseUrl || process.env.LOXIA_BACKEND_URL || 'http://localhost:3001';
708
+ const videoUrl = `${backendUrl}/llm/video-content/${generationId}?model=${job.model || ''}`;
709
+
710
+ this.logger?.info(`Video content URL: ${videoUrl}`, { generationId });
711
+
712
+ // Resolve output path and download
713
+ const resolvedOutputPath = await this._resolveOutputPath(job);
714
+
715
+ let savedToDisk = false;
716
+ let downloadError = null;
717
+
718
+ try {
719
+ await fs.mkdir(path.dirname(resolvedOutputPath), { recursive: true });
720
+ await this._downloadVideo(videoUrl, resolvedOutputPath, job.sessionId);
721
+ savedToDisk = true;
722
+
723
+ // Schedule cleanup if temp file
724
+ if (!job.saveToProject) {
725
+ this._scheduleCleanup(resolvedOutputPath, job.jobId);
726
+ }
727
+ } catch (err) {
728
+ downloadError = err.message;
729
+ this.logger?.warn(`Failed to save video to disk: ${err.message}`);
730
+ }
731
+
732
+ job.status = 'completed';
733
+ job.result = {
734
+ jobId: job.jobId,
735
+ soraJobId: job.soraJobId,
736
+ prompt: job.prompt,
737
+ outputPath: job.outputPath,
738
+ resolvedOutputPath: savedToDisk ? resolvedOutputPath : null,
739
+ temporaryUrl: videoUrl,
740
+ savedToDisk,
741
+ downloadError,
742
+ success: true,
743
+ model: job.model,
744
+ width: job.width,
745
+ height: job.height,
746
+ duration: job.duration,
747
+ generations: status.generations || []
748
+ };
749
+ job.completedAt = new Date().toISOString();
750
+
751
+ this.completedJobs.set(job.jobId, job);
752
+
753
+ // Durable gallery copy — non-blocking, non-fatal. Skipped when
754
+ // the agent has opted out via `toolConfig.video-gen.saveToGallery`.
755
+ if (savedToDisk && resolvedOutputPath && job.toolConfig?.saveToGallery !== false) {
756
+ try {
757
+ let agentName = null;
758
+ if (this.agentPool && job.agentId) {
759
+ try {
760
+ const a = await this.agentPool.getAgent(job.agentId);
761
+ agentName = a?.name || null;
762
+ } catch { /* non-fatal */ }
763
+ }
764
+ await getGalleryService(this.logger).saveVideo({
765
+ sourcePath: resolvedOutputPath,
766
+ metadata: {
767
+ prompt: job.prompt,
768
+ model: job.model,
769
+ width: job.width,
770
+ height: job.height,
771
+ duration: job.duration,
772
+ variants: job.variants,
773
+ agentId: job.agentId,
774
+ agentName,
775
+ sessionId: job.sessionId,
776
+ jobId: job.jobId,
777
+ soraJobId: job.soraJobId,
778
+ createdAt: job.completedAt,
779
+ },
780
+ });
781
+ } catch (galErr) {
782
+ this.logger?.warn?.('Gallery save failed (non-fatal)', {
783
+ jobId: job.jobId,
784
+ error: galErr.message,
785
+ });
786
+ }
787
+ }
788
+
789
+ // Broadcast success
790
+ this._broadcastJobResult(job, savedToDisk ? resolvedOutputPath : null, videoUrl, savedToDisk);
791
+
792
+ // Save to conversation history
793
+ await this._saveToConversationHistory(job, false);
794
+
795
+ } catch (error) {
796
+ this.logger?.error(`Error handling job completion: ${job.jobId}`, error);
797
+ await this._handleJobFailed(job, error);
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Handle job failure
803
+ * @private
804
+ */
805
+ async _handleJobFailed(job, error) {
806
+ // Clear poll timer
807
+ const timer = this.pollTimers.get(job.jobId);
808
+ if (timer) {
809
+ clearTimeout(timer);
810
+ this.pollTimers.delete(job.jobId);
811
+ }
812
+
813
+ // Remove from active jobs
814
+ this.activeJobs.delete(job.jobId);
815
+
816
+ job.status = 'failed';
817
+ job.error = error.message;
818
+ job.completedAt = new Date().toISOString();
819
+
820
+ this.completedJobs.set(job.jobId, job);
821
+
822
+ // Broadcast error
823
+ this._broadcastJobStatus(job, 'failed', error.message);
824
+
825
+ // Save error to conversation history
826
+ await this._saveToConversationHistory(job, true);
827
+ }
828
+
829
+ /**
830
+ * Broadcast job status update
831
+ * @private
832
+ */
833
+ _broadcastJobStatus(job, status, message) {
834
+ if (global.loxiaWebServer && job.sessionId) {
835
+ global.loxiaWebServer.broadcastToSession(job.sessionId, {
836
+ type: 'videoJobStatus',
837
+ agentId: job.agentId,
838
+ jobId: job.jobId,
839
+ soraJobId: job.soraJobId,
840
+ status,
841
+ message,
842
+ prompt: job.prompt,
843
+ timestamp: new Date().toISOString()
844
+ });
845
+ }
846
+ }
847
+
848
+ /**
849
+ * Broadcast job result
850
+ * @private
851
+ */
852
+ _broadcastJobResult(job, localPath, videoUrl, savedToDisk) {
853
+ this.logger?.info('📢 Broadcasting video result', {
854
+ jobId: job.jobId,
855
+ savedToDisk,
856
+ localPath,
857
+ originalVideoUrl: videoUrl,
858
+ hasWebServer: !!global.loxiaWebServer,
859
+ sessionId: job.sessionId
860
+ });
861
+
862
+ if (global.loxiaWebServer && job.sessionId) {
863
+ // Convert to web URL if saved locally
864
+ let webUrl = videoUrl;
865
+ if (savedToDisk && localPath) {
866
+ webUrl = this._convertToWebUrl(localPath, job.sessionId);
867
+ }
868
+
869
+ global.loxiaWebServer.broadcastToSession(job.sessionId, {
870
+ type: 'videoGenerated',
871
+ agentId: job.agentId,
872
+ jobId: job.jobId,
873
+ soraJobId: job.soraJobId,
874
+ videoUrl: webUrl,
875
+ localPath,
876
+ prompt: job.prompt,
877
+ success: true,
878
+ savedToDisk,
879
+ isTemporary: !savedToDisk,
880
+ width: job.width,
881
+ height: job.height,
882
+ duration: job.duration,
883
+ timestamp: job.completedAt
884
+ });
885
+
886
+ this.logger?.info('Video generation broadcast sent', {
887
+ jobId: job.jobId,
888
+ savedToDisk
889
+ });
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Save result to conversation history
895
+ * @private
896
+ */
897
+ async _saveToConversationHistory(job, isError) {
898
+ if (!this.agentPool || !job.agentId) {
899
+ return;
900
+ }
901
+
902
+ try {
903
+ const agent = await this.agentPool.getAgent(job.agentId);
904
+ if (!agent) {
905
+ return;
906
+ }
907
+
908
+ let message;
909
+
910
+ if (isError) {
911
+ message = {
912
+ id: `vid-error-${job.jobId}`,
913
+ role: 'system',
914
+ content: `Video generation failed: ${job.error}\n\n**Prompt:** ${job.prompt}`,
915
+ timestamp: job.completedAt,
916
+ type: 'error',
917
+ toolId: 'video-gen',
918
+ status: 'failed',
919
+ jobId: job.jobId
920
+ };
921
+ } else {
922
+ let content = `Video generated: ${job.prompt}`;
923
+
924
+ if (!job.result.savedToDisk) {
925
+ content += '\n\nWarning: Video is using a temporary URL (expires in ~24 hours).';
926
+ if (job.result.downloadError) {
927
+ content += `\n**Error:** ${job.result.downloadError}`;
928
+ }
929
+ }
930
+
931
+ message = {
932
+ id: `vid-result-${job.jobId}`,
933
+ role: 'assistant',
934
+ content,
935
+ timestamp: job.completedAt,
936
+ // Use persistent URL (session-independent) for conversation history
937
+ // This allows videos to work after restart
938
+ videoUrl: job.result.savedToDisk
939
+ ? this._convertToPersistentUrl(job.persistentFilename || path.basename(job.result.resolvedOutputPath))
940
+ : job.result.temporaryUrl,
941
+ // Also store filename for fallback lookups
942
+ videoFilename: job.persistentFilename || (job.result.resolvedOutputPath ? path.basename(job.result.resolvedOutputPath) : null),
943
+ type: 'video-result',
944
+ toolId: 'video-gen',
945
+ status: 'completed',
946
+ isTemporary: !job.result.savedToDisk,
947
+ savedToDisk: job.result.savedToDisk,
948
+ width: job.width,
949
+ height: job.height,
950
+ duration: job.duration
951
+ };
952
+ }
953
+
954
+ // Add to full conversation
955
+ agent.conversations.full.messages.push(message);
956
+ agent.conversations.full.lastUpdated = job.completedAt;
957
+
958
+ // Add to current model conversation if exists
959
+ if (agent.currentModel && agent.conversations[agent.currentModel]) {
960
+ agent.conversations[agent.currentModel].messages.push(message);
961
+ agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
962
+ }
963
+
964
+ agent.lastActivity = job.completedAt;
965
+
966
+ await this.agentPool.persistAgentState(job.agentId);
967
+
968
+ this.logger?.info('Video result saved to conversation history', {
969
+ agentId: job.agentId,
970
+ jobId: job.jobId,
971
+ isError
972
+ });
973
+
974
+ // Queue tool result so agent "sees" the completion/failure and can continue
975
+ if (isError) {
976
+ await this.agentPool.addToolResult(job.agentId, {
977
+ toolId: 'video-gen',
978
+ status: 'failed',
979
+ error: job.error,
980
+ result: {
981
+ jobId: job.jobId,
982
+ prompt: job.prompt
983
+ },
984
+ timestamp: job.completedAt
985
+ });
986
+ } else {
987
+ await this.agentPool.addToolResult(job.agentId, {
988
+ toolId: 'video-gen',
989
+ status: 'completed',
990
+ result: {
991
+ jobId: job.jobId,
992
+ prompt: job.prompt,
993
+ videoUrl: message.videoUrl,
994
+ localPath: job.result.resolvedOutputPath,
995
+ savedToDisk: job.result.savedToDisk,
996
+ isTemporary: !job.result.savedToDisk,
997
+ width: job.width,
998
+ height: job.height,
999
+ duration: job.duration
1000
+ },
1001
+ timestamp: job.completedAt
1002
+ });
1003
+ }
1004
+
1005
+ this.logger?.info('Video result queued for agent processing', {
1006
+ agentId: job.agentId,
1007
+ jobId: job.jobId,
1008
+ isError
1009
+ });
1010
+
1011
+ } catch (error) {
1012
+ this.logger?.error('Failed to save video result to conversation history', {
1013
+ error: error.message,
1014
+ agentId: job.agentId,
1015
+ jobId: job.jobId
1016
+ });
1017
+ }
1018
+ }
1019
+
1020
+ /**
1021
+ * Resolve output path - save to agent's working directory for persistence
1022
+ * @private
1023
+ */
1024
+ async _resolveOutputPath(job) {
1025
+ // Use agent's working directory from directoryAccess settings
1026
+ // Fall back to projectDir, then cwd
1027
+ const workingDir = job.directoryAccess?.workingDirectory || job.projectDir || process.cwd();
1028
+ const videosDir = path.join(workingDir, 'generated-videos');
1029
+
1030
+ // Create videos directory
1031
+ await fs.mkdir(videosDir, { recursive: true });
1032
+
1033
+ let filename;
1034
+ if (job.outputPath) {
1035
+ // User specified a path - use it if within working directory
1036
+ if (path.isAbsolute(job.outputPath)) {
1037
+ // Absolute path - validate it's within working directory
1038
+ const normalizedPath = path.normalize(job.outputPath);
1039
+ if (!normalizedPath.startsWith(path.normalize(workingDir))) {
1040
+ throw new Error('Output path must be within agent working directory');
1041
+ }
1042
+ // Use the full path, create parent dirs
1043
+ await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
1044
+ job.saveToProject = true;
1045
+ job.persistentFilename = path.basename(normalizedPath);
1046
+ return normalizedPath;
1047
+ } else {
1048
+ // Relative path - resolve relative to working directory
1049
+ const resolvedPath = path.normalize(path.join(workingDir, job.outputPath));
1050
+ if (!resolvedPath.startsWith(path.normalize(workingDir))) {
1051
+ throw new Error('Path traversal detected');
1052
+ }
1053
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
1054
+ job.saveToProject = true;
1055
+ job.persistentFilename = path.basename(resolvedPath);
1056
+ return resolvedPath;
1057
+ }
1058
+ } else {
1059
+ // Generate filename from job ID in generated-videos folder
1060
+ filename = `video-${job.jobId}.mp4`;
1061
+ }
1062
+
1063
+ const resolvedPath = path.join(videosDir, filename);
1064
+
1065
+ this.logger?.info('📁 Video will be saved to agent directory', {
1066
+ workingDir,
1067
+ filename,
1068
+ resolvedPath
1069
+ });
1070
+
1071
+ // Mark job as saved to project for correct URL generation
1072
+ job.saveToProject = true;
1073
+ job.persistentFilename = filename;
1074
+
1075
+ return resolvedPath;
1076
+ }
1077
+
1078
+ /**
1079
+ * Download video from URL
1080
+ * @private
1081
+ */
1082
+ async _downloadVideo(videoUrl, outputPath, sessionId) {
1083
+ try {
1084
+ // Get API key for authentication with backend
1085
+ let apiKey = null;
1086
+ if (this.aiService?.apiKeyManager) {
1087
+ const keys = this.aiService.apiKeyManager.getKeysForRequest(null);
1088
+ apiKey = keys.loxiaApiKey;
1089
+ }
1090
+ if (!apiKey) {
1091
+ apiKey = process.env.LOXIA_API_KEY;
1092
+ }
1093
+
1094
+ const headers = {};
1095
+ if (apiKey) {
1096
+ headers['Authorization'] = `Bearer ${apiKey}`;
1097
+ }
1098
+
1099
+ this.logger?.info('📥 Downloading video', {
1100
+ url: videoUrl,
1101
+ hasApiKey: !!apiKey,
1102
+ apiKeySource: apiKey ? (this.aiService?.apiKeyManager ? 'apiKeyManager' : 'env') : 'none',
1103
+ outputPath,
1104
+ sessionId
1105
+ });
1106
+
1107
+ const response = await fetch(videoUrl, {
1108
+ headers,
1109
+ signal: AbortSignal.timeout(VIDEO_CONFIG.DOWNLOAD_TIMEOUT)
1110
+ });
1111
+
1112
+ if (!response.ok) {
1113
+ const errorText = await response.text().catch(() => '');
1114
+ throw new Error(`Failed to download video: HTTP ${response.status} - ${errorText}`);
1115
+ }
1116
+
1117
+ const buffer = Buffer.from(await response.arrayBuffer());
1118
+ await fs.writeFile(outputPath, buffer);
1119
+
1120
+ this.logger?.info(`Video saved to: ${outputPath}`);
1121
+
1122
+ } catch (error) {
1123
+ this.logger?.error('❌ Video download failed', {
1124
+ errorName: error.name,
1125
+ errorMessage: error.message,
1126
+ url: videoUrl,
1127
+ outputPath
1128
+ });
1129
+
1130
+ if (error.name === 'TimeoutError') {
1131
+ throw new Error('Video download timeout', { cause: error });
1132
+ } else if (error.name === 'TypeError') {
1133
+ throw new Error(`Network error: ${error.message}`, { cause: error });
1134
+ } else {
1135
+ throw new Error(`Download failed: ${error.message}`, { cause: error });
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ /**
1141
+ * Schedule cleanup of temp file
1142
+ * @private
1143
+ */
1144
+ _scheduleCleanup(filePath, jobId) {
1145
+ const timer = setTimeout(async () => {
1146
+ try {
1147
+ await fs.unlink(filePath);
1148
+ this.logger?.debug(`Cleaned up temp video: ${filePath}`);
1149
+ this.cleanupTimers.delete(jobId);
1150
+ } catch {
1151
+ // File might already be deleted, ignore
1152
+ }
1153
+ }, VIDEO_CONFIG.TEMP_CLEANUP_MS);
1154
+
1155
+ this.cleanupTimers.set(jobId, timer);
1156
+ }
1157
+
1158
+ /**
1159
+ * Convert local file path to web-accessible URL
1160
+ * @private
1161
+ */
1162
+ _convertToWebUrl(localPath, sessionId) {
1163
+ const filename = path.basename(localPath);
1164
+ const port = global.loxiaWebServer?.port || 8080;
1165
+ let host = global.loxiaWebServer?.host || 'localhost';
1166
+
1167
+ if (host === '0.0.0.0') {
1168
+ host = 'localhost';
1169
+ }
1170
+
1171
+ const webUrl = `http://${host}:${port}/api/videos/${sessionId}/${filename}`;
1172
+
1173
+ this.logger?.info('🔗 Converting local path to web URL', {
1174
+ localPath,
1175
+ sessionId,
1176
+ webUrl
1177
+ });
1178
+
1179
+ return webUrl;
1180
+ }
1181
+
1182
+ /**
1183
+ * Convert filename to persistent (session-independent) URL
1184
+ * These URLs work after browser refresh and system restart
1185
+ * @private
1186
+ */
1187
+ _convertToPersistentUrl(filename) {
1188
+ const port = global.loxiaWebServer?.port || 8080;
1189
+ let host = global.loxiaWebServer?.host || 'localhost';
1190
+
1191
+ if (host === '0.0.0.0') {
1192
+ host = 'localhost';
1193
+ }
1194
+
1195
+ // Use the session-independent endpoint that searches all agent directories
1196
+ const persistentUrl = `http://${host}:${port}/api/generated-videos/${filename}`;
1197
+
1198
+ this.logger?.info('🔗 Generated persistent URL for video', {
1199
+ filename,
1200
+ persistentUrl
1201
+ });
1202
+
1203
+ return persistentUrl;
1204
+ }
1205
+
1206
+ /**
1207
+ * Estimate wait time based on queue
1208
+ * @private
1209
+ */
1210
+ _estimateWaitTime() {
1211
+ const avgGenerationTime = 300; // 5 minutes in seconds
1212
+ const queuePosition = this.queue.length;
1213
+ const activeJobs = this.activeJobs.size;
1214
+
1215
+ if (queuePosition === 0 && activeJobs === 0) {
1216
+ return '~5 minutes';
1217
+ }
1218
+
1219
+ // Calculate based on queue position and concurrent limit
1220
+ const waitingJobs = queuePosition + activeJobs;
1221
+ const batches = Math.ceil(waitingJobs / VIDEO_CONFIG.MAX_CONCURRENT);
1222
+ const estimatedSeconds = batches * avgGenerationTime;
1223
+
1224
+ const minutes = Math.floor(estimatedSeconds / 60);
1225
+
1226
+ if (minutes >= 60) {
1227
+ const hours = Math.floor(minutes / 60);
1228
+ const remainingMinutes = minutes % 60;
1229
+ return `~${hours}h ${remainingMinutes}m`;
1230
+ }
1231
+
1232
+ return `~${minutes} minutes`;
1233
+ }
1234
+
1235
+ /**
1236
+ * Generate unique job ID
1237
+ * @private
1238
+ */
1239
+ _generateJobId() {
1240
+ return `vid-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1241
+ }
1242
+
1243
+ /**
1244
+ * Get job status
1245
+ * @param {string} jobId - Job ID
1246
+ * @returns {Object} Job status
1247
+ */
1248
+ getJobStatus(jobId) {
1249
+ // Check completed jobs
1250
+ if (this.completedJobs.has(jobId)) {
1251
+ return this.completedJobs.get(jobId);
1252
+ }
1253
+
1254
+ // Check active jobs
1255
+ if (this.activeJobs.has(jobId)) {
1256
+ return this.activeJobs.get(jobId);
1257
+ }
1258
+
1259
+ // Check queue
1260
+ const queuedJob = this.queue.find(job => job.jobId === jobId);
1261
+ if (queuedJob) {
1262
+ return queuedJob;
1263
+ }
1264
+
1265
+ return {
1266
+ jobId,
1267
+ status: 'not_found'
1268
+ };
1269
+ }
1270
+
1271
+ /**
1272
+ * Cleanup on shutdown
1273
+ */
1274
+ async cleanup() {
1275
+ this.logger?.info('Shutting down VideoTool');
1276
+
1277
+ // Clear all cleanup timers
1278
+ for (const timer of this.cleanupTimers.values()) {
1279
+ clearTimeout(timer);
1280
+ }
1281
+ this.cleanupTimers.clear();
1282
+
1283
+ // Clear all poll timers
1284
+ for (const timer of this.pollTimers.values()) {
1285
+ clearTimeout(timer);
1286
+ }
1287
+ this.pollTimers.clear();
1288
+
1289
+ // Mark queued jobs as cancelled
1290
+ for (const job of this.queue) {
1291
+ job.status = 'cancelled';
1292
+ }
1293
+ this.queue = [];
1294
+
1295
+ // Mark active jobs as cancelled
1296
+ for (const job of this.activeJobs.values()) {
1297
+ job.status = 'cancelled';
1298
+ }
1299
+ this.activeJobs.clear();
1300
+ }
1301
+ }
1302
+
1303
+ export default VideoTool;